SwiftUI/UIKit Introspection broken on iOS 14 - swift

I have an app that uses a UIViewRepresentable and view introspection to create pull to refresh functionality for ScrollViews in my SwiftUI app and it worked in iOS 13. However, iOS 14 has broken it and I don't know how to fix it. Here is the implementation:
import SwiftUI
import Introspect
private struct PullToRefresh: UIViewRepresentable {
#Binding var isShowing: Bool
let onRefresh: () -> Void
public init(
isShowing: Binding<Bool>,
onRefresh: #escaping () -> Void
) {
_isShowing = isShowing
self.onRefresh = onRefresh
}
public class Coordinator {
let onRefresh: () -> Void
let isShowing: Binding<Bool>
init(
onRefresh: #escaping () -> Void,
isShowing: Binding<Bool>
) {
self.onRefresh = onRefresh
self.isShowing = isShowing
}
#objc
func onValueChanged() {
isShowing.wrappedValue = true
onRefresh()
}
}
public func makeUIView(context: UIViewRepresentableContext<PullToRefresh>) -> UIView {
let view = UIView(frame: .zero)
view.isHidden = true
view.isUserInteractionEnabled = false
return view
}
private func tableView(entry: UIView) -> UIScrollView? {
// Search in ancestors - FAILS HERE
if let tableView = Introspect.findAncestor(ofType: UIScrollView.self, from: entry) {
return tableView
}
guard let viewHost = Introspect.findViewHost(from: entry) else {
return nil
}
// Search in siblings - FAILS HERE
return Introspect.previousSibling(containing: UIScrollView.self, from: viewHost)
}
public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PullToRefresh>) {
DispatchQueue.main.asyncAfter(deadline: .now()) {
guard let tableView = self.tableView(entry: uiView) else {
return
}
if let refreshControl = tableView.refreshControl {
if self.isShowing {
refreshControl.beginRefreshing()
} else {
refreshControl.endRefreshing()
}
return
}
let refreshControl = UIRefreshControl()
refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.onValueChanged), for: .valueChanged)
tableView.refreshControl = refreshControl
}
}
public func makeCoordinator() -> Coordinator {
return Coordinator(onRefresh: onRefresh, isShowing: $isShowing)
}
}
extension View {
public func pullToRefresh(isShowing: Binding<Bool>, onRefresh: #escaping () -> Void) -> some View {
return overlay(
PullToRefresh(isShowing: isShowing, onRefresh: onRefresh)
.frame(width: 0, height: 0)
, alignment: .center)
}
}
This code is slightly modified from this repository.
Here is a rough version of the usage:
NavigationView {
VStack(alignment: .center) {
...
GeometryReader { geometry in
ScrollView(.vertical) {
...
}
.pullToRefresh(isShowing: self.$refreshingEvents, onRefresh: refreshEvents)
}
}.navigationBarTitle("Events", displayMode: .inline)
}
When I use this, the ScrollView doesn't have a refresh indicator attached. Moving the pullToRefresh to a child view of the ScrollView doesn't change anything. Here is what is returned from introspecting:
PlatformViewHost<PlatformViewRepresentableAdaptor<PullToRefresh>>
_UIHostingView<ModifiedContent<Element, StyleContextWriter<SidebarStyleContext>>>
UIViewControllerWrapperView
UINavigationTransitionView
UILayoutContainerView
_UIPanelControllerContentView
_UISplitViewControllerPanelImplView
PlatformViewHost<PlatformViewControllerRepresentableAdaptor<MulticolumnSplitViewRepresentable<Element, Never, _UnaryViewAdaptor<EmptyView>>>>
_UIHostingView<_ViewList_View>
UIViewControllerWrapperView
UITransitionView
UILayoutContainerView
PlatformViewHost<PlatformViewControllerRepresentableAdaptor<UIKitTabView>>
_UIHostingView<ModifiedContent<MainView, _EnvironmentKeyWritingModifier<Optional<UserData>>>>
UIDropShadowView
UITransitionView
UIWindow
There should be a UIScrollView somewhere in there, but there isn't on iOS 14. Is there a way I could fix my current code or update it with an alternative that would retain the same functionality?

Related

Custom Mouse Up/Down View Modifier Prevents View Hierarchy from Updating

I'm trying to implement a custom view modifier to detect mouse up/down events within a SwiftUI view hierarchy. This is my solution so far:
extension View {
func onMouseDown(action: #escaping (Bool) -> Void) -> some View {
MouseDownView(action: action) { self }
}
}
struct MouseDownView<Content: View>: View {
let action: (Bool) -> Void
let content: () -> Content
init(action: #escaping (Bool) -> Void, #ViewBuilder content: #escaping () -> Content) {
self.action = action
self.content = content
}
var body: some View {
MouseDownRepresentable(action: action, content: content())
}
}
struct MouseDownRepresentable<Content: View>: NSViewRepresentable {
let action: (Bool) -> Void
let content: Content
func makeNSView(context: Context) -> NSHostingView<Content> {
MouseDownHostingView(action: action, rootView: content)
}
func updateNSView(_ view: NSHostingView<Content>, context: Context) {
}
}
class MouseDownHostingView<Content: View>: NSHostingView<Content> {
let action: (Bool) -> Void
init(action: #escaping (Bool) -> Void, rootView: Content) {
self.action = action
super.init(rootView: rootView)
}
required init(rootView: Content) {
fatalError()
}
required init?(coder: NSCoder) {
fatalError()
}
override func mouseDown(with event: NSEvent) {
action(true)
}
override func mouseUp(with event: NSEvent) {
action(false)
}
}
It works, in the sense that the closure passed to onMouseDown is getting called whenever a mouse up/down event occurs within the the view that it was applied to (even taking non-rectangular shapes into account). Unfortunately though, there is an issue that prevents the view from updating when any of its #State variables get modified inside the passed closure.
Example usage:
struct PlayerView<Content: View>: View {
private let content: () -> Content
#State var isPlaying = true
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
ZStack {
content()
ZStack {
Circle()
.foregroundStyle(.secondary)
Image(systemName: isPlaying ? "play.fill" : "pause.fill")
.foregroundColor(.white)
.font(.system(size: 40))
}
.onMouseDown { isDown in
if isDown {
// ...
} else {
isPlaying.toggle()
}
}
.frame(width: 80, height: 80)
}
}
}
isPlaying gets toggled everytime the circular view is clicked, but the image never updates. How can this be? Does this configuration of an NSViewRepresentable inside of an NSHostingView somehow mean, that the modified view is no longer formally part of the original view hierarchy, and therefore not allowed to update it?
I tried wrapping isPlaying as a published property inside of an ObservableObject that gets attached to the view as a #StateObject, but it showed the same behavior.
Does anyone know what's going on here and/or how to work around it?
Yes, SwiftUI does not see dependency through NSView bridge anymore, so instead the content should remain in SwiftUI world, but modifier/handler be placed above it, like
extension View {
func onMouseDown(action: #escaping (Bool) -> Void) -> some View {
self.overlay(
MouseDownView(action: action) {
Color.clear.contentShape(Rectangle())
})
}
}
Tested with Xcode 13.4 / macOS 12.5

Popover crash when update value using UIViewControllerRepresentable

I want to make popover on iPhone , I notice when using .popover in iPhone it will always show as sheet , but on iPad it will show as popover
so I decide to use UIKit version
everything is working fine until I tap on Button to update the view
it will crash with this error
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Application tried to present modally a view controller <_TtGC7SwiftUI19UIHostingControllerGVS_6HStackGVS_9TupleViewTGVS_6ButtonVS_4Text_S4_GS3_S4______: 0x7fd47b424df0> that is already being presented by <UIViewController: 0x7fd47b426200>.
My code :
struct PopoverViewModifier<PopoverContent>: ViewModifier where PopoverContent: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: () -> PopoverContent
func body(content: Content) -> some View {
content
.background(
Popover(
isPresented: self.$isPresented,
onDismiss: self.onDismiss,
content: self.content
)
)
}
}
extension View {
func popover<Content>(
isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
content: #escaping () -> Content
) -> some View where Content: View {
ModifiedContent(
content: self,
modifier: PopoverViewModifier(
isPresented: isPresented,
onDismiss: onDismiss,
content: content
)
)
}
}
struct Popover<Content: View> : UIViewControllerRepresentable {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
#ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, content: self.content())
}
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
let host = context.coordinator.host
if self.isPresented {
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: Int.max , height: Int.max))
host.modalPresentationStyle = UIModalPresentationStyle.popover
host.popoverPresentationController?.delegate = context.coordinator
host.popoverPresentationController?.sourceView = uiViewController.view
host.popoverPresentationController?.sourceRect = uiViewController.view.bounds
uiViewController.present(host, animated: true, completion: nil)
}
else {
host.dismiss(animated: true, completion: nil)
}
}
class Coordinator: NSObject, UIPopoverPresentationControllerDelegate {
let host: UIHostingController<Content>
private let parent: Popover
init(parent: Popover, content: Content) {
self.parent = parent
self.host = UIHostingController(rootView: content)
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.parent.isPresented = false
if let onDismiss = self.parent.onDismiss {
onDismiss()
}
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}
}
How I use it :
struct ContentView: View {
#State var openChangeFont = false
#State var currentFontSize = 0
var body: some View {
NavigationView {
Text("Test")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
Text("Popover")
.popover(isPresented: $openChangeFont, content: {
HStack {
Button(action: {
DispatchQueue.main.async {
currentFontSize += 2
}
}, label: {
Text("Increase")
})
Text("\(currentFontSize)")
Button(action: {
currentFontSize -= 2
}, label: {
Text("Decrease")
})
}
})
.onTapGesture {
openChangeFont.toggle()
}
}
}
}
}
Put a breakpoint in Popover.updateUIViewController and I think you’ll catch the problem. updateUIViewController is called every time the SwiftUI view is updated, which means it may be called when isPresented is true and you the popover is already being presented.
If that’s the issue, then you need to track whether you’re already presenting the popover or not. You’re already implementing UIPopoverPresentationControllerDelegate so you can use that.

SwiftUI Binding Edits Wrong TextField after Item Reordered

Xcode 13 beta 5, iOS 14, macOS 11.6
I have a parent SwiftUI view that lists some children. Each child is bound to an NSViewRepresentable. Everything works and I can edit the values as expected. But once I reorder the items in the list and edit a field, it edits the wrong field. It appears that the binding remains intact from the previous item order.
Here's what that looks like:
Here's the parent:
struct ParentView: View {
#StateObject var model = ThingModel.shared
var body: some View {
VStack{
ForEach($model.things){ $thing in
ChildView(thing: $thing)
//Reorder
.onDrag{
model.draggedThing = thing
return NSItemProvider(object: NSString())
}
}
Text("Value: \(model.value)").font(.title)
}
.frame(width:300, height: 200)
}
}
...and here's the child view:
struct ChildView: View {
#Binding var thing: Thing
#StateObject var model = ThingModel.shared
var body: some View{
HStack{
GrowingField(text: $thing.text, submit: {
model.value = thing.text
print(thing.text)
})
Text(" = ")
.opacity(0.4)
}
.padding(10)
.onDrop(of: [UTType.text], delegate: ThingReorderDelegate(hoveredThing: thing))
}
}
Last of all, here is the NSViewRepresentable which is called GrowingField. For simplicity, I have omitted the NSTextField subclass.
struct GrowingField: NSViewRepresentable{
#Binding var text: String
var submit:(() -> Void)? //Hit enter
func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField()
textField.delegate = context.coordinator
textField.stringValue = text
return textField
}
func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.stringValue = text
context.coordinator.textBinding = $text
}
//Delegates
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextFieldDelegate {
let parent: GrowingField
var textBinding : Binding<String>?
init(_ field: GrowingField) {
self.parent = field
}
func controlTextDidChange(_ obj: Notification) {
guard let textField = obj.object as? NSTextField else { return }
self.textBinding?.wrappedValue = textField.stringValue
}
//Listen for certain keyboard keys
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector{
case #selector(NSStandardKeyBindingResponding.insertNewline(_:)):
//- Enter -
parent.submit?()
textView.window?.makeFirstResponder(nil) //Blur cursor
return true
default:
return false
}
}
}
}
Why does the binding to the NSViewRepresentable not follow the field after it is reordered?
Here is a sample project to download and try it out.
I believe the issue (bug?) is with the ForEach-generated binding.
If you forego the generated binding and create your own, everything seems to work as expected.
Added to the ThingModel:
func bindingForThing(id: String) -> Binding<Thing> {
.init {
self.things.first { $0.id == id }!
} set: { newThing in
self.things = self.things.map { $0.id == id ? newThing : $0 }
}
}
And the ParentView:
ForEach(model.things){ thing in
ChildView(thing: model.bindingForThing(id: thing.id))

Handle single click and double click while updating the view

I'm trying to implement a single and double click for grid view items on macOS. On the first click it should highlight the item. On the second click it should open a detail view.
struct MyGridView: View {
var items: [String]
#State var selectedItem: String?
var body: some View {
LazyVGrid(columns: [
GridItem(.adaptive(minimum: 200, maximum: 270))
], alignment: .center, spacing: 8, pinnedViews: []) {
ForEach(items, id: \.self) { item in
Text("Test")
.gesture(TapGesture(count: 2).onEnded {
print("double clicked")
})
.simultaneousGesture(TapGesture().onEnded {
self.selectedItem = item
})
.background(self.selectedItem == item ? Color.red : .blue)
}
}
}
Problem
The highlighting works as expected without delay. But because the first click updates the view, the TapGesture for the double click is ignored. Only once the item was selected before, the double click also works, because it doesn't need to update the view again.
I was following https://stackoverflow.com/a/59992192/1752496 but it doesn't consider view updates after the first click.
You can use a custom click handler like this:
class TapState {
static var taps: [String: Date] = [:]
static func isDoubleTap<Content: View>(for view: Content) -> Bool {
let key = "\(view)"
let date = taps[key]
taps[key] = Date()
if let date = date, date.timeIntervalSince1970 >= Date().timeIntervalSince1970 - 0.4 {
return true
} else {
return false
}
}
}
extension View {
public func onTapGesture(firstTap: #escaping () -> Void, secondTap: #escaping () -> Void) -> some View {
onTapGesture(count: 1) {
if TapState.isDoubleTap(for: self) {
secondTap()
} else {
firstTap()
}
}
}
}
And implement it like this:
struct MyView: View {
#State var isSelected: Bool = false
#State var isShowingDetail: Bool = false
var body: some View {
Text("Text")
.background(isSelected ? Color.green : Color.blue)
.onTapGesture(firstTap: {
isSelected = true
}, secondTap: {
isShowingDetail = true
})
.sheet(isPresented: $isShowingDetail) {
Text("Detail")
}
}
}
Swift Package
I've also created a small package you can use: https://github.com/lukaswuerzburger/DoubleTap
As far as I know there is no build in function or something like that to understand deference between singleTap or doubleTap, but we have endless freedom to build what we want, I found this way:
You can apply tapRecognizer to anything you want, will work the same! for showing the use case I just applied to a Circle().
import SwiftUI
struct ContentView: View {
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 100, height: 100, alignment: .center)
.tapRecognizer(tapSensitivity: 0.2, singleTapAction: singleTapAction, doubleTapAction: doubleTapAction)
}
func singleTapAction() { print("singleTapAction") }
func doubleTapAction() { print("doubleTapAction") }
}
struct TapRecognizerViewModifier: ViewModifier {
#State private var singleTapIsTaped: Bool = Bool()
var tapSensitivity: Double
var singleTapAction: () -> Void
var doubleTapAction: () -> Void
init(tapSensitivity: Double, singleTapAction: #escaping () -> Void, doubleTapAction: #escaping () -> Void) {
self.tapSensitivity = tapSensitivity
self.singleTapAction = singleTapAction
self.doubleTapAction = doubleTapAction
}
func body(content: Content) -> some View {
return content
.gesture(simultaneouslyGesture)
}
private var singleTapGesture: some Gesture { TapGesture(count: 1).onEnded{
singleTapIsTaped = true
DispatchQueue.main.asyncAfter(deadline: .now() + tapSensitivity) { if singleTapIsTaped { singleTapAction() } }
} }
private var doubleTapGesture: some Gesture { TapGesture(count: 2).onEnded{ singleTapIsTaped = false; doubleTapAction() } }
private var simultaneouslyGesture: some Gesture { singleTapGesture.simultaneously(with: doubleTapGesture) }
}
extension View {
func tapRecognizer(tapSensitivity: Double, singleTapAction: #escaping () -> Void, doubleTapAction: #escaping () -> Void) -> some View {
return self.modifier(TapRecognizerViewModifier(tapSensitivity: tapSensitivity, singleTapAction: singleTapAction, doubleTapAction: doubleTapAction))
}
}

SwiftUI sheet() modals with custom size on iPad

How can I control the preferred presentation size of a modal sheet on iPad with SwiftUI? I'm surprised how hard it is to find an answer on Google for this.
Also, what is the best way to know if the modal is dismissed by dragging it down (cancelled) or actually performing a custom positive action?
Here is my solution for showing a form sheet on an iPad in SwiftUI:
struct MyView: View {
#State var show = false
var body: some View {
Button("Open Sheet") { self.show = true }
.formSheet(isPresented: $show) {
Text("Form Sheet Content")
}
}
}
Enabled by this UIViewControllerRepresentable
class FormSheetWrapper<Content: View>: UIViewController, UIPopoverPresentationControllerDelegate {
var content: () -> Content
var onDismiss: (() -> Void)?
private var hostVC: UIHostingController<Content>?
required init?(coder: NSCoder) { fatalError("") }
init(content: #escaping () -> Content) {
self.content = content
super.init(nibName: nil, bundle: nil)
}
func show() {
guard hostVC == nil else { return }
let vc = UIHostingController(rootView: content())
vc.view.sizeToFit()
vc.preferredContentSize = vc.view.bounds.size
vc.modalPresentationStyle = .formSheet
vc.presentationController?.delegate = self
hostVC = vc
self.present(vc, animated: true, completion: nil)
}
func hide() {
guard let vc = self.hostVC, !vc.isBeingDismissed else { return }
dismiss(animated: true, completion: nil)
hostVC = nil
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
hostVC = nil
self.onDismiss?()
}
}
struct FormSheet<Content: View> : UIViewControllerRepresentable {
#Binding var show: Bool
let content: () -> Content
func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> FormSheetWrapper<Content> {
let vc = FormSheetWrapper(content: content)
vc.onDismiss = { self.show = false }
return vc
}
func updateUIViewController(_ uiViewController: FormSheetWrapper<Content>,
context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
}
extension View {
public func formSheet<Content: View>(isPresented: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) -> some View {
self.background(FormSheet(show: isPresented,
content: content))
}
}
You should be able to modify the code in func show() according to UIKit specs in order to get the sizing the way you like (and you can even go so far as to inject parameters from the SwiftUI side if needed). This is just how I get a form sheet to work on iPad as .sheet was just too big for my use case
I just posted the same in this SO How can I make a background color with opacity on a Sheet view?
but it seems to do exactly what I need it to do. It makes the background of the sheet transparent while allowing the content to be sized as needed to appear as if it's the only part of the sheet. Works great on the iPad.
Using the AWESOME answer from #Asperi that I have been trying to find all day, I have built a simple view modifier that can now be applied inside a .sheet or .fullScreenCover modal view and provides a transparent background. You can then set the frame modifier for the content as needed to fit the screen without the user having to know the modal is not custom sized.
import SwiftUI
struct ClearBackgroundView: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
let view = UIView()
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .clear
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
struct ClearBackgroundViewModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(ClearBackgroundView())
}
}
extension View {
func clearModalBackground()->some View {
self.modifier(ClearBackgroundViewModifier())
}
}
Usage:
.sheet(isPresented: $isPresented) {
ContentToDisplay()
.frame(width: 300, height: 400)
.clearModalBackground()
}
In case it helps anyone else, I was able to get this working by leaning on this code to hold the view controller:
https://gist.github.com/timothycosta/a43dfe25f1d8a37c71341a1ebaf82213
struct ViewControllerHolder {
weak var value: UIViewController?
init(_ value: UIViewController?) {
self.value = value
}
}
struct ViewControllerKey: EnvironmentKey {
static var defaultValue: ViewControllerHolder? { ViewControllerHolder(UIApplication.shared.windows.first?.rootViewController) }
}
extension EnvironmentValues {
var viewController: ViewControllerHolder? {
get { self[ViewControllerKey.self] }
set { self[ViewControllerKey.self] = newValue }
}
}
extension UIViewController {
func present<Content: View>(
presentationStyle: UIModalPresentationStyle = .automatic,
transitionStyle _: UIModalTransitionStyle = .coverVertical,
animated: Bool = true,
completion: #escaping () -> Void = { /* nothing by default*/ },
#ViewBuilder builder: () -> Content
) {
let toPresent = UIHostingController(rootView: AnyView(EmptyView()))
toPresent.modalPresentationStyle = presentationStyle
toPresent.rootView = AnyView(
builder()
.environment(\.viewController, ViewControllerHolder(toPresent))
)
if presentationStyle == .overCurrentContext {
toPresent.view.backgroundColor = .clear
}
present(toPresent, animated: animated, completion: completion)
}
}
Coupled with a specialized view to handle common elements in the modal:
struct ModalContentView<Content>: View where Content: View {
// Use this function to provide the content to display and to bring up the modal.
// Currently only the 'formSheet' style has been tested but it should work with any
// modal presentation style from UIKit.
public static func present(_ content: Content, style: UIModalPresentationStyle = .formSheet) {
let modal = ModalContentView(content: content)
// Present ourselves
modal.viewController?.present(presentationStyle: style) {
modal.body
}
}
// Grab the view controller out of the environment.
#Environment(\.viewController) private var viewControllerHolder: ViewControllerHolder?
private var viewController: UIViewController? {
viewControllerHolder?.value
}
// The content to be displayed in the view.
private var content: Content
public var body: some View {
VStack {
/// Some specialized controls, like X button to close omitted...
self.content
}
}
Finally, simply call:
ModalContentView.present( MyAwesomeView() )
to display MyAwesomeView inside of a .formSheet modal.
There are some issues in #ccwasden's answer. Dismissing popover won't change $isPresented all the time, as delegate is not set and hostVC is never assigned.
Here are some modifications required.
In FlexSheetWrapper:
func show() {
guard hostVC == nil else { return }
let vc = UIHostingController(rootView: content())
vc.view.sizeToFit()
vc.preferredContentSize = vc.view.bounds.size
vc.modalPresentationStyle = .formSheet
vc.presentationController?.delegate = self
hostVC = vc
self.present(vc, animated: true, completion: nil)
}
And in FormSheet:
func updateUIViewController(_ uiViewController: FlexSheetWrapper<Content>,
context: UIViewControllerRepresentableContext<FlexSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
From #ccwasden answer, I fixed the problem when you $isPresented = true at the beginning of code, the modal will not present when the view is loaded, To do so here is code View+FormSheet.swift
Result
// You can now set `test = true` at first
.formSheet(isPresented: $test) {
Text("Hi")
}
View+FormSheet.swift
import SwiftUI
class ModalUIHostingController<Content>: UIHostingController<Content>, UIPopoverPresentationControllerDelegate where Content : View {
var onDismiss: (() -> Void)
required init?(coder: NSCoder) { fatalError("") }
init(onDismiss: #escaping () -> Void, rootView: Content) {
self.onDismiss = onDismiss
super.init(rootView: rootView)
view.sizeToFit()
preferredContentSize = view.bounds.size
modalPresentationStyle = .formSheet
presentationController?.delegate = self
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
print("modal dismiss")
onDismiss()
}
}
class ModalUIViewController<Content: View>: UIViewController {
var isPresented: Bool
var content: () -> Content
var onDismiss: (() -> Void)
private var hostVC: ModalUIHostingController<Content>
private var isViewDidAppear = false
required init?(coder: NSCoder) { fatalError("") }
init(isPresented: Bool = false, onDismiss: #escaping () -> Void, content: #escaping () -> Content) {
self.isPresented = isPresented
self.onDismiss = onDismiss
self.content = content
self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
super.init(nibName: nil, bundle: nil)
}
func show() {
guard isViewDidAppear else { return }
self.hostVC = ModalUIHostingController(onDismiss: onDismiss, rootView: content())
present(hostVC, animated: true)
}
func hide() {
guard !hostVC.isBeingDismissed else { return }
dismiss(animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
isViewDidAppear = true
if isPresented {
show()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
isViewDidAppear = false
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
show()
}
}
struct FormSheet<Content: View> : UIViewControllerRepresentable {
#Binding var show: Bool
let content: () -> Content
func makeUIViewController(context: UIViewControllerRepresentableContext<FormSheet<Content>>) -> ModalUIViewController<Content> {
let onDismiss = {
self.show = false
}
let vc = ModalUIViewController(isPresented: show, onDismiss: onDismiss, content: content)
return vc
}
func updateUIViewController(_ uiViewController: ModalUIViewController<Content>,
context: UIViewControllerRepresentableContext<FormSheet<Content>>) {
if show {
uiViewController.show()
}
else {
uiViewController.hide()
}
}
}
extension View {
public func formSheet<Content: View>(isPresented: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) -> some View {
self.background(FormSheet(show: isPresented,
content: content))
}
}