Is it possible to have a SwiftUI button load an AR Quick Look preview?

Hello,

I'm having a blast learning SwiftUI but as a designer, and brand new developer, I'm really struggling with linking things and I don't quite have the vocabulary to follow Apple's documentation. With SwiftUI, I'd just love to have a button middle of the screen--I tap it and an AR Quick Look USDZ pops up.


Could somebody point me in the right direction, connecting Quick Look to a button? And pretend like you're talking to a baby?


https://developer.apple.com/documentation/arkit/previewing_a_model_with_ar_quick_look


Thank you!


-Dan

Accepted Reply

So what you describe is initially easy to put together with SwiftUI. First you need a Button, so use this for your app's ContentView:


struct ContentView: View {
    var body: some View {
        Button("Show Preview") {
            // Action that runs when the button is tapped goes here...
        }
    }
}


SwiftUI will automatically center that button on the screen. When you tap it you want to show another view, and a simple way to do that is with what's called a modal view. Modal here means that it takes over the screen, so you can now only interact with this new view until you dismiss it, while the original view stays in the background. In SwiftUI, modal views are presented as sheets—that is, they slide up from the bottom of the screen to cover the original view, leaving a little space at the top so you can see that it's covering another view.


To display a sheet in SwiftUI, you need two things: first, you need a property that defines a piece of state from which SwiftUI can derive your views. This state is just a boolean value: true or false. If it's true, the sheet is displayed, and if not, it's dismissed. Secondly, you apply a modifier to your view—something that changes your view's output—to tell SwiftUI what you want displayed, and which state property to use with it. You then tie it all into the button by changing the button's action to toggle that state variable. When it's off, tapping the button will turn it on, displaying the sheet. When the sheet is dismissed, the state will be set to false, ready for your button to change it again.


Update your content view like so:


struct ContentView: View {
    // State used to toggle showing our sheet containing AR QL preview
    @State var showingPreview = false
    
    var body: some View {
        VStack {
            Button("Show Preview") {
                // Action that runs when the button is tapped.
                // This one toggles the showing-preview state.
                self.showingPreview.toggle()
            }
            // This modifier tells SwiftUI to present a view in a modal sheet
            // when a state variable is set to true, and to dismiss it
            // when set to false.
            .sheet(isPresented: $showingPreview) {
                // Sheet content here...
            }
        }
    }
}


The view now contains one property, named 'showingPreview', set to false. It has the '@State' attribute, which tells SwiftUI that this is part of the view's state information. SwiftUI now watches it closely, and any time it's changed, your view will be updated.


I've also wrapped the button inside a VStack view. It's not changing much now, but we'll add something else in a moment. A VStack is a view that contains other views, such as your Button, and places them onscreen in order from top to bottom, one above the next.


The .sheet(isPresented:content:) function takes a binding to the state property as its first parameter. The special $-prefix syntax is used in SwiftUI to generate bindings from state. A binding is a reference back to another property—in this case, your 'showingPreview' property—that can be used to both read and write that property's value. By handing in a binding rather than just the plain property you enable SwiftUI to set the state to false when the sheet is dismissed.


Your sheet will need a couple of things itself: it needs its quick-look content view, and it would also be nice to have a visible 'close' button to dismiss the sheet (in case the user doesn't know about the built-in swipe-to-dismiss gesture). You'll implement this using a VStack and an HStack. Your VStack will contain the HStack at the top, then under that will be the quick-look view. Your HStack (which lays out leading-to-trailing) will contain the Close button.


Here's the implementation:


.sheet(isPresented: $showingPreview) {
    // Sheet content: the quick look view with a header bar containing
    // a simple 'close' button that closes the sheet.
    VStack {
        // Top row: button, aligned left
        HStack {
            Button("Close") {
                // Toggle the preview display off again.
                // Swiping down (the system gesture to dismiss a sheet)
                // will cause SwiftUI to toggle our state property as well.
                self.showingPreview.toggle()
            }
            // The spacer fills the space following the button, in effect
            // pushing the button to the leading edge of the view.
            Spacer()
        }
        .padding()
        
        // Quick-look view goes here
    }
}


The 'close' button is doing the exact same thing as the 'show preview' button: it's toggling the state property that controls the sheet. If the 'close' button is visible, then the sheet is being shown, and toggling the property will hide it.


Your quick-look view requires a little more work, though, because it's not something that SwiftUI provides directly. Instead, you need to create a new SwiftUI view that will wrap a QLPreviewController, which is what implements QuickLook preview. This is designed to work with the UIKit framework, so you'll create a different type of view to manage it.


Create a new SwiftUI file, and name it 'ARQuickLook.swift'. Use the following content:


import SwiftUI
import QuickLook
import ARKit

struct ARQuickLookView: UIViewControllerRepresentable {
    // Properties: the file name (without extension), and whether we'll let
    // the user scale the preview content.
    var name: String
    var allowScaling: Bool = true
    
    func makeCoordinator() -> ARQuickLookView.Coordinator {
        // The coordinator object implements the mechanics of dealing with
        // the live UIKit view controller.
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> QLPreviewController {
        // Create the preview controller, and assign our Coordinator class
        // as its data source.
        let controller = QLPreviewController()
        controller.dataSource = context.coordinator
        return controller
    }
    
    func updateUIViewController(_ controller: QLPreviewController,
                                context: Context) {
        // nothing to do here
    }
    
    class Coordinator: NSObject, QLPreviewControllerDataSource {
        let parent: ARQuickLookView
        private lazy var fileURL: URL = Bundle.main.url(forResource: parent.name,
                                                        withExtension: "reality")!
        
        init(_ parent: ARQuickLookView) {
            self.parent = parent
            super.init()
        }
        
        // The QLPreviewController asks its delegate how many items it has:
        func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
            return 1
        }
        
        // For each item (see method above), the QLPreviewController asks for
        // a QLPreviewItem instance describing that item:
        func previewController(
            _ controller: QLPreviewController,
            previewItemAt index: Int
        ) -> QLPreviewItem {
            guard let fileURL = Bundle.main.url(forResource: parent.name, withExtension: "usdz") else {
                fatalError("Unable to load \(parent.name).reality from main bundle")
            }
            
            let item = ARQuickLookPreviewItem(fileAt: fileURL)
            item.allowsContentScaling = parent.allowScaling
            return item
        }
    }
}

struct ARQuickLookView_Previews: PreviewProvider {
    static var previews: some View {
        ARQuickLookView(name: "MyScene")
    }
}


There's a fair bit going on in here. First, you're creating a type of UIViewControllerRepresentable, which is a special View subtype. Instead of defining a 'body' as you did in your ContentView, here you provide a few methods that will be used to create a UIKit view controller, and then to updated that view controller as necessary. SwiftUI will handle storage for you.


Now, to display a QuickLook preview, you used a QLPreviewController. That controller makes use of a common pattern in iOS development: a data source. This is another object that it will query to find out what data it should display. In order to provide a data source, you've defined the Coordinator class inside your ARQuickLookView. This is also managed by SwiftUI on your behalf, and is made available through the special Context passed into the various methods you see above. You create an instance of this coordinator in the makeCoordinator() method; your coordinator takes a copy of the ARQuickLookView in order to refer back to its 'name' and 'allowScaling' properties.


In makeUIViewController(context:), you create your QLPreviewController, then fetch your coordinator from the context and assign it as the preview controller's data source. In updateViewController(_:context:), you don't need to do anything, but the method must be implemented or your app won't build.


Inside the coordinator you implement the two required QLPreviewControllerDataSource methods: in numberOfPreviewItems(in:), you return a count of the items it'll display. Here you're only ever doing one, so you return 1.


In previewController(_:previewItemAt:), you return the requested preview item. Here you ask your application bundle to find the named item (for 'name' it would search for 'name.usdz'). If that fails, you print an error and exit—that's what the fatalError() call does. Otherwise, you use it to create an ARQuickLookPreviewItem, and then set the 'allowsContentScaling' property on that to match the value in the ARQuickLookView. You return that item, and the QLPreviewController will do what it takes to make it appear onscreen.


At this point, you need to go back and add one last line to your ContentView, ad the end of the '.sheet' modifier. Replace the comment about 'Quick-look view goes here' with a call to create the view itself, replacing 'MyScene' with the name of the ARKit file you added to your application:


// The actual quick-look view, which will scale to fill the
// available space.
ARQuickLookView(name: "MyScene")


Your app ought to work now. However, as an exercise, can you work out how to let the user change the 'allow scaling' property of the preview? You'll need a state property and a Toggle view. Have a look through the framework and see if you can figure it out. When you're done, scroll down to see my implementation, and see how close you got.





.

.

.

.





.

.

.

.



.

.

.

.



struct ContentView: View {
    // State used to toggle showing our sheet containing AR QL preview
    @State var showingPreview = false
    
    // Turns off & on the ability to change the preview size within ARKit.
    @State var allowsScaling = false
    
    var body: some View {
        VStack {
            // Allow the user to enable/disable scaling of the preview content.
            Toggle("Allow Scaling", isOn: $allowsScaling)
            
            Button("Show Preview") {
                // Action that runs when the button is tapped.
                // This one toggles the showing-preview state.
                self.showingPreview.toggle()
            }
            // This modifier tells SwiftUI to present a view in a modal sheet
            // when a state variable is set to true, and to dismiss it
            // when set to false.
            .sheet(isPresented: $showingPreview) {
                // Sheet content: the quick look view with a header bar containing
                // a simple 'close' button that closes the sheet.
                VStack {
                    // Top row: button, aligned left
                    HStack {
                        Button("Close") {
                            // Toggle the preview display off again.
                            // Swiping down (the system gesture to dismiss a sheet)
                            // will cause SwiftUI to toggle our state property as well.
                            self.showingPreview.toggle()
                        }
                        // The spacer fills the space following the button, in effect
                        // pushing the button to the leading edge of the view.
                        Spacer()
                    }
                    // Leave a little space all around the button.
                    .padding()
                    
                    // The actual quick-look view, which will scale to fill the
                    // available space.
                    ARQuickLookView(name: "MyScene", allowScaling: self.allowsScaling)
                }
            }
        }
    }
}
  • Hi Jim,

    This answer you posted has helped me tremendously with my own project, however, I was wondering if it was possible to use this method to display model from within the apps file system (using FileManager to produce the url for this line "guard let fileURL = Bundle.main.url(forResource: parent.name, withExtension: "usdz")" I have created a new question for this at: https://developer.apple.com/forums/thread/689586 if you have any experience doing this I would much appreciate the help.

    Thanks, Louis

Add a Comment

Replies

So what you describe is initially easy to put together with SwiftUI. First you need a Button, so use this for your app's ContentView:


struct ContentView: View {
    var body: some View {
        Button("Show Preview") {
            // Action that runs when the button is tapped goes here...
        }
    }
}


SwiftUI will automatically center that button on the screen. When you tap it you want to show another view, and a simple way to do that is with what's called a modal view. Modal here means that it takes over the screen, so you can now only interact with this new view until you dismiss it, while the original view stays in the background. In SwiftUI, modal views are presented as sheets—that is, they slide up from the bottom of the screen to cover the original view, leaving a little space at the top so you can see that it's covering another view.


To display a sheet in SwiftUI, you need two things: first, you need a property that defines a piece of state from which SwiftUI can derive your views. This state is just a boolean value: true or false. If it's true, the sheet is displayed, and if not, it's dismissed. Secondly, you apply a modifier to your view—something that changes your view's output—to tell SwiftUI what you want displayed, and which state property to use with it. You then tie it all into the button by changing the button's action to toggle that state variable. When it's off, tapping the button will turn it on, displaying the sheet. When the sheet is dismissed, the state will be set to false, ready for your button to change it again.


Update your content view like so:


struct ContentView: View {
    // State used to toggle showing our sheet containing AR QL preview
    @State var showingPreview = false
    
    var body: some View {
        VStack {
            Button("Show Preview") {
                // Action that runs when the button is tapped.
                // This one toggles the showing-preview state.
                self.showingPreview.toggle()
            }
            // This modifier tells SwiftUI to present a view in a modal sheet
            // when a state variable is set to true, and to dismiss it
            // when set to false.
            .sheet(isPresented: $showingPreview) {
                // Sheet content here...
            }
        }
    }
}


The view now contains one property, named 'showingPreview', set to false. It has the '@State' attribute, which tells SwiftUI that this is part of the view's state information. SwiftUI now watches it closely, and any time it's changed, your view will be updated.


I've also wrapped the button inside a VStack view. It's not changing much now, but we'll add something else in a moment. A VStack is a view that contains other views, such as your Button, and places them onscreen in order from top to bottom, one above the next.


The .sheet(isPresented:content:) function takes a binding to the state property as its first parameter. The special $-prefix syntax is used in SwiftUI to generate bindings from state. A binding is a reference back to another property—in this case, your 'showingPreview' property—that can be used to both read and write that property's value. By handing in a binding rather than just the plain property you enable SwiftUI to set the state to false when the sheet is dismissed.


Your sheet will need a couple of things itself: it needs its quick-look content view, and it would also be nice to have a visible 'close' button to dismiss the sheet (in case the user doesn't know about the built-in swipe-to-dismiss gesture). You'll implement this using a VStack and an HStack. Your VStack will contain the HStack at the top, then under that will be the quick-look view. Your HStack (which lays out leading-to-trailing) will contain the Close button.


Here's the implementation:


.sheet(isPresented: $showingPreview) {
    // Sheet content: the quick look view with a header bar containing
    // a simple 'close' button that closes the sheet.
    VStack {
        // Top row: button, aligned left
        HStack {
            Button("Close") {
                // Toggle the preview display off again.
                // Swiping down (the system gesture to dismiss a sheet)
                // will cause SwiftUI to toggle our state property as well.
                self.showingPreview.toggle()
            }
            // The spacer fills the space following the button, in effect
            // pushing the button to the leading edge of the view.
            Spacer()
        }
        .padding()
        
        // Quick-look view goes here
    }
}


The 'close' button is doing the exact same thing as the 'show preview' button: it's toggling the state property that controls the sheet. If the 'close' button is visible, then the sheet is being shown, and toggling the property will hide it.


Your quick-look view requires a little more work, though, because it's not something that SwiftUI provides directly. Instead, you need to create a new SwiftUI view that will wrap a QLPreviewController, which is what implements QuickLook preview. This is designed to work with the UIKit framework, so you'll create a different type of view to manage it.


Create a new SwiftUI file, and name it 'ARQuickLook.swift'. Use the following content:


import SwiftUI
import QuickLook
import ARKit

struct ARQuickLookView: UIViewControllerRepresentable {
    // Properties: the file name (without extension), and whether we'll let
    // the user scale the preview content.
    var name: String
    var allowScaling: Bool = true
    
    func makeCoordinator() -> ARQuickLookView.Coordinator {
        // The coordinator object implements the mechanics of dealing with
        // the live UIKit view controller.
        Coordinator(self)
    }
    
    func makeUIViewController(context: Context) -> QLPreviewController {
        // Create the preview controller, and assign our Coordinator class
        // as its data source.
        let controller = QLPreviewController()
        controller.dataSource = context.coordinator
        return controller
    }
    
    func updateUIViewController(_ controller: QLPreviewController,
                                context: Context) {
        // nothing to do here
    }
    
    class Coordinator: NSObject, QLPreviewControllerDataSource {
        let parent: ARQuickLookView
        private lazy var fileURL: URL = Bundle.main.url(forResource: parent.name,
                                                        withExtension: "reality")!
        
        init(_ parent: ARQuickLookView) {
            self.parent = parent
            super.init()
        }
        
        // The QLPreviewController asks its delegate how many items it has:
        func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
            return 1
        }
        
        // For each item (see method above), the QLPreviewController asks for
        // a QLPreviewItem instance describing that item:
        func previewController(
            _ controller: QLPreviewController,
            previewItemAt index: Int
        ) -> QLPreviewItem {
            guard let fileURL = Bundle.main.url(forResource: parent.name, withExtension: "usdz") else {
                fatalError("Unable to load \(parent.name).reality from main bundle")
            }
            
            let item = ARQuickLookPreviewItem(fileAt: fileURL)
            item.allowsContentScaling = parent.allowScaling
            return item
        }
    }
}

struct ARQuickLookView_Previews: PreviewProvider {
    static var previews: some View {
        ARQuickLookView(name: "MyScene")
    }
}


There's a fair bit going on in here. First, you're creating a type of UIViewControllerRepresentable, which is a special View subtype. Instead of defining a 'body' as you did in your ContentView, here you provide a few methods that will be used to create a UIKit view controller, and then to updated that view controller as necessary. SwiftUI will handle storage for you.


Now, to display a QuickLook preview, you used a QLPreviewController. That controller makes use of a common pattern in iOS development: a data source. This is another object that it will query to find out what data it should display. In order to provide a data source, you've defined the Coordinator class inside your ARQuickLookView. This is also managed by SwiftUI on your behalf, and is made available through the special Context passed into the various methods you see above. You create an instance of this coordinator in the makeCoordinator() method; your coordinator takes a copy of the ARQuickLookView in order to refer back to its 'name' and 'allowScaling' properties.


In makeUIViewController(context:), you create your QLPreviewController, then fetch your coordinator from the context and assign it as the preview controller's data source. In updateViewController(_:context:), you don't need to do anything, but the method must be implemented or your app won't build.


Inside the coordinator you implement the two required QLPreviewControllerDataSource methods: in numberOfPreviewItems(in:), you return a count of the items it'll display. Here you're only ever doing one, so you return 1.


In previewController(_:previewItemAt:), you return the requested preview item. Here you ask your application bundle to find the named item (for 'name' it would search for 'name.usdz'). If that fails, you print an error and exit—that's what the fatalError() call does. Otherwise, you use it to create an ARQuickLookPreviewItem, and then set the 'allowsContentScaling' property on that to match the value in the ARQuickLookView. You return that item, and the QLPreviewController will do what it takes to make it appear onscreen.


At this point, you need to go back and add one last line to your ContentView, ad the end of the '.sheet' modifier. Replace the comment about 'Quick-look view goes here' with a call to create the view itself, replacing 'MyScene' with the name of the ARKit file you added to your application:


// The actual quick-look view, which will scale to fill the
// available space.
ARQuickLookView(name: "MyScene")


Your app ought to work now. However, as an exercise, can you work out how to let the user change the 'allow scaling' property of the preview? You'll need a state property and a Toggle view. Have a look through the framework and see if you can figure it out. When you're done, scroll down to see my implementation, and see how close you got.





.

.

.

.





.

.

.

.



.

.

.

.



struct ContentView: View {
    // State used to toggle showing our sheet containing AR QL preview
    @State var showingPreview = false
    
    // Turns off & on the ability to change the preview size within ARKit.
    @State var allowsScaling = false
    
    var body: some View {
        VStack {
            // Allow the user to enable/disable scaling of the preview content.
            Toggle("Allow Scaling", isOn: $allowsScaling)
            
            Button("Show Preview") {
                // Action that runs when the button is tapped.
                // This one toggles the showing-preview state.
                self.showingPreview.toggle()
            }
            // This modifier tells SwiftUI to present a view in a modal sheet
            // when a state variable is set to true, and to dismiss it
            // when set to false.
            .sheet(isPresented: $showingPreview) {
                // Sheet content: the quick look view with a header bar containing
                // a simple 'close' button that closes the sheet.
                VStack {
                    // Top row: button, aligned left
                    HStack {
                        Button("Close") {
                            // Toggle the preview display off again.
                            // Swiping down (the system gesture to dismiss a sheet)
                            // will cause SwiftUI to toggle our state property as well.
                            self.showingPreview.toggle()
                        }
                        // The spacer fills the space following the button, in effect
                        // pushing the button to the leading edge of the view.
                        Spacer()
                    }
                    // Leave a little space all around the button.
                    .padding()
                    
                    // The actual quick-look view, which will scale to fill the
                    // available space.
                    ARQuickLookView(name: "MyScene", allowScaling: self.allowsScaling)
                }
            }
        }
    }
}
  • Hi Jim,

    This answer you posted has helped me tremendously with my own project, however, I was wondering if it was possible to use this method to display model from within the apps file system (using FileManager to produce the url for this line "guard let fileURL = Bundle.main.url(forResource: parent.name, withExtension: "usdz")" I have created a new question for this at: https://developer.apple.com/forums/thread/689586 if you have any experience doing this I would much appreciate the help.

    Thanks, Louis

Add a Comment

Hi Jim,


I'm humbled and incredibly grateful that you spent so much time helping me through this. I learned a ton from your post and got to work through the problem over the weekend. I was super frustrated before and now I can move forward again. Thank you isn't a big enough word.


I followed your directions using the ".sheet" technique and it worked great. The only issue is the .sheet view cut off the AR Quick Look built-in UI and I couldn't figure out how to make it go full screen, so figured out I could put everything in a ZStack and have my showingPreview @State toggle the ARQuickLook view on top of everything, using an if/then statement.


if showingPreview {

ARQuickLookView(name: "robot")

.edgesIgnoringSafeArea(.all)

} else {

}


Everything seems to be working with that except the Quick Look Preview interface's built-in escape button doesn't do anything. I don't see a way to connect it and have it toggle my showingPreview @State. It might be cheating, but I can cover it up by putting another escape button on the top layer of my ZStack. Does that sound OK or do you think there's a better way?


Thank you, thank you again.


-Dan

I’m trying to implement a QuickLook preview of an image with SwiftUI. Thank you for posting this example. Almost got it working. In my case, it is a regular QLPreview of an image, not AR. So I could simplify this code and make it work.


Here’s a problem I have: whether I push the preview into navigation stack with NavigationLink or show it with the sheet technique, it only shows the image itself (fully interactive and zoomable, which is great), but it does not show any of the surrounding controls (title, share button) etc. It is just a blank interface with the image. Am I doing anything wrong / is there anything missing in your example to also get the title and Share button for a regular QLPreview?

  • Dealing with this right now. I can only get it to work by using UIKit instead of SwiftUI, however I want to use SwiftUI.

Add a Comment

Hi Jaanus, Im struggling with the same issue, no matter how i insert my wrapped QLPreview inside my swiftui contentview it only shows the image or pdf document. The title, share button, etc. is missing. I could not find a solution yet, hopefully someone else did.

Hi Jim,

Firstly, that's a really amazing reply to the question - it helped me a lot with my own project. One thing that was a little confusing is that in your explanation you said, "Here you ask your application bundle to find the named item (for 'name' it would search for 'name.usdz')" but actually in your code you look for a .reality file. This led me to implement my own function with a signature where you can pass in the file type.


private lazy var fileURL: URL = Bundle.main.url(forResource: parent.name, withExtension: parent.arFileType)!


Also, as a side note for those who haven't seen this yet a good tip is that, "If you include a Reality Composer file (

.rcproject
) in your app's Copy Files build phase, Xcode automatically outputs a converted
.reality
file in your app bundle at build time." So in other words don't add a .reality file directly to your source list, add the original .rcproject file instead.


I do have one question for you - on this page for class QLPreviewController, it says that is supports, "3D models in USDZ format (with both standalone and AR views for viewing the model". In your sample code the preview is in full augmented reality mode using the camera, etc. But I'd like to be able to offer my users a way to preview a model just on a white background (ie. "standalone" mode). I just can't find how to do this. Any help would be most appreciated!


- Dane

hello, daniel,have you sloved the AR Quick Look build-in UI absence problem ?

I have got the same problem, It's works OK except no build-in UI, so i can't exchange to the Object model.

  • It's interesting, the quicklook AI is there if you present from a UIViewController or storyboard, but not if the QLPreviewController is wrapped in a UIViewControllerRepresentable.

Add a Comment

This broke for me with the iOS 15 update. Anyone have an idea why?

I get the following error:

Class QLPreviewExtensionHostContext does not conform to aux host protocol: QLRemotePreviewHost