iosswiftswiftuiuikituiactivityviewcontroller

UIActivityViewController displayed in the incorrect position on iPads when presented as a popover in SwiftUI


I currently working on an iOS app that requires me to support iOS 15+.

I've developed a custom SwiftUI button view called ShareButton

The goal of this button is to make the share functionality reusable across the app so that I can add this ShareButton to any view and have the ability to share it's contents.

I have a navigation router that centralizes transitions, presentations and pushes to other screens and the code in this router is UIKit based.

I use a UIActivityViewController to share.

This is my code:

struct ShareButton: View {
    let url: URL?

    var body: some View {
        shareButton
            .simultaneousGesture(TapGesture().onEnded() {
                completion?()
            })
    }

    @ViewBuilder
    private var shareButton: some View {
        if let url {
            Button {
                navigationRouter.presentActivityViewController(activityItems: [url],
                                                               applicationActivities: [])
            } label: {
                Image(systemName: Constants.Image.shareIcon)
            }
        }
    }

And in my router I have something like this:

func presentActivityViewController(activityItems: [Any], applicationActivities: [UIActivity]?) {
    let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
    activityViewController.popoverPresentationController?.sourceView = UIView()
    
    activityViewController.presentInKeyWindowPresentedController()
}

On the iPhone, this works well, however, on the iPad, the share popover originates on the top left of the screen.

I can see that my popoverPresentationController sourceView is a random UIView, so this behavior makes sense and brings me to my first question, is there a way to pass my ShareButton here as a UIView instead ?

What i tried was:

  1. changing the sourceView to the top most view controller
  2. figuring out the frame of my button using GeometryReader and passing it to the presentActivity function
  3. Passing the frame to the presentActivity function and using it as the sourceRect of the popoverPresentationController

This does give me results that are quite close to what I want, however, it is not perfect as the popover hides the button.

So my questions are:

  1. What would be the best way to achieve this using my architecture ?
  2. Is it possible to pass the swiftUI View as a UIView to the popoverPresentationController.sourceView ?

Solution

  • You can get access to a UIView in the updateUIView method of a UIViewRepresentable. If you make the UIViewRepresentable the background of the ShareButton, it will have the same frame as the share button.

    The hard part is, how to trigger an action in the UIViewRepresentable in the action closure of the Button?

    Here is an implementation of a UIViewProvider, where it uses an extra @State to determine whether or not to run the action. Similar to SwiftUI APIs like .animation and .sensoryFeedback, it takes a trigger parameter, and performs the viewAction when the trigger changes.

    struct UIViewProvider<Value: Equatable>: View {
        struct Wrapper: UIViewRepresentable {
            let viewAction: (UIView) -> Void
            
            func makeUIView(context: Context) -> UIView {
                UIView()
            }
            
            func updateUIView(_ uiView: UIView, context: Context) {
                // run this asynchronously, so that viewAction is not run during a view update
                DispatchQueue.main.async {
                    viewAction(uiView)
                }
            }
        }
        
        let trigger: Value
        let viewAction: (UIView) -> Void
        
        @State private var shouldTrigger = false
        
        var body: some View {
            Wrapper { v in
                if shouldTrigger {
                    viewAction(v)
                    shouldTrigger = false
                }
            }
            .onChange(of: trigger) {
                shouldTrigger = true
            }
        }
    }
    

    Then ShareButton can use this as a background:

    struct ShareButton: View {
        let url: URL
        let completion: (() -> Void)?
        @State private var trigger = false
        
        init(url: URL, completion: (() -> Void)? = nil) {
            self.url = url
            self.completion = completion
        }
        
        var body: some View {
            Button {
                trigger.toggle()
            } label: {
                Image(systemName: "square.and.arrow.up")
            }
            .background {
                UIViewProvider(trigger: trigger) { view in
                    // remember to change presentActivityViewController to take a sourceView parameter
                    navigationRouter.presentActivityViewController(
                        sourceView: view, activityItems: [url], applicationActivities: []
                    )
                    completion?()
                }
            }
        }
    }
    

    Alternatively, you can also get a UIView from a SwiftUI view using SwiftUI-Introspect.

    You can introspect on iOS 15, and for future versions, use the built-in ShareLink.

    struct ShareButton: View {
        @State private var view: UIView?
        let url: URL
        let completion: (() -> Void)?
        
        init(url: URL, completion: (() -> Void)? = nil) {
            self.url = url
            self.completion = completion
        }
        
        var body: some View {
            if #available(iOS 16, *) {
                ShareLink(item: url) {
                    Text("Share")
                }
                .simultaneousGesture(TapGesture().onEnded { _ in
                    completion?()
                })
            } else {
                Button("Share") {
                    if let view {
                        // remember to change presentActivityViewController to take a sourceView parameter
                        navigationRouter.presentActivityViewController(
                            sourceView: view, 
                            activityItems: [url], 
                            applicationActivities: []
                        )
                        completion?()
                    }
                }
                .introspect(.view, on: .iOS(.v15)) { v in
                    // assign self.view asynchronously so we are not modifying @State during a view update
                    DispatchQueue.main.async {
                        self.view = v
                    }
                }
            }
        }
    }