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:
GeometryReader
and passing it to the presentActivity functionpresentActivity
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:
popoverPresentationController.sourceView
?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
}
}
}
}
}