From 19af36624129930adca286729f402818c1af8db0 Mon Sep 17 00:00:00 2001 From: Zheng Date: Sun, 16 Jan 2022 19:51:43 -0800 Subject: [PATCH 1/4] Add window resizing support --- Sources/Popover+Lifecycle.swift | 10 +++-- Sources/Popover.swift | 2 +- Sources/PopoverGestureContainer.swift | 61 +++++++++++++++++++-------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/Sources/Popover+Lifecycle.swift b/Sources/Popover+Lifecycle.swift index fa3a20f..4ee88c4 100644 --- a/Sources/Popover+Lifecycle.swift +++ b/Sources/Popover+Lifecycle.swift @@ -28,7 +28,7 @@ public extension Popover { /** Add the popover to the container view. */ - let displayPopover: () -> Void = { + let displayPopover = { withTransaction(transaction) { model.add(self) } @@ -44,14 +44,16 @@ public extension Popover { } else { container = PopoverGestureContainer(frame: window.bounds) - /// Wait until the container is present in the view hiearchy before showing the popover, otherwise all the - /// layout math will be working with wonky frames. + /** + Wait until the container is present in the view hierarchy before showing the popover, + otherwise all the layout math will be working with wonky frames. + */ container.onMovedToWindow = displayPopover window.addSubview(container) } - /// Hang onto the container for future dismiss/replace actions. + /// Hang on to the container for future dismiss/replace actions. context.presentedPopoverContainer = container } diff --git a/Sources/Popover.swift b/Sources/Popover.swift index b9b04f5..e31411c 100644 --- a/Sources/Popover.swift +++ b/Sources/Popover.swift @@ -373,7 +373,7 @@ public struct Popover: Identifiable { if let window = presentedPopoverContainer?.window { return window } else { - print("This popover is not tied to a window. Please file a bug report (https://github.com/aheze/Popovers/issues)") + print("[Popovers] - This popover is not tied to a window. Please file a bug report (https://github.com/aheze/Popovers/issues).") return UIWindow() } } diff --git a/Sources/PopoverGestureContainer.swift b/Sources/PopoverGestureContainer.swift index 72e8bf1..cc5ac1b 100644 --- a/Sources/PopoverGestureContainer.swift +++ b/Sources/PopoverGestureContainer.swift @@ -1,5 +1,5 @@ // -// PopoverContainerViewController.swift +// PopoverGestureContainer.swift // Popovers // // Created by A. Zheng (github.com/aheze) on 12/23/21. @@ -8,29 +8,52 @@ import SwiftUI +/// A hosting view for `PopoverContainerView` with tap filtering. class PopoverGestureContainer: UIView { - /// A closure to be invoked when this view is inserted into a window's view hiearchy. + /// A closure to be invoked when this view is inserted into a window's view hierarchy. var onMovedToWindow: (() -> Void)? + /// Create a new `PopoverGestureContainer`. + override init(frame: CGRect) { + super.init(frame: frame) + + /// Allow resizing. + self.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + + override func layoutSubviews() { + super.layoutSubviews() + + /// Orientation or screen bounds changed. Update popover frames. + popoverModel.updateFrames() + } + override func didMoveToWindow() { super.didMoveToWindow() - - if let window = window { - let popoverContainerView = PopoverContainerView(popoverModel: popoverModel) - .environment(\.window, window) - - let hostingController = UIHostingController(rootView: popoverContainerView) - hostingController.view.frame = bounds - hostingController.view.backgroundColor = .clear - hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - - addSubview(hostingController.view) - setNeedsLayout() - layoutIfNeeded() - - onMovedToWindow?() + + guard let window = window else { + print("[Popovers] - `PopoverGestureContainer` does not have a parent window. Please file a bug report (https://github.com/aheze/Popovers/issues).") + return } + + /// Create the SwiftUI view that contains all the popovers. + let popoverContainerView = PopoverContainerView(popoverModel: popoverModel) + .environment(\.window, window) /// Inject the window. + + let hostingController = UIHostingController(rootView: popoverContainerView) + hostingController.view.frame = bounds + hostingController.view.backgroundColor = .clear + hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + addSubview(hostingController.view) + + /// Ensure the view is laid out so that SwiftUI animations don't stutter. + setNeedsLayout() + layoutIfNeeded() + + /// Let the presenter know that its window is available. + onMovedToWindow?() } /** @@ -104,4 +127,8 @@ class PopoverGestureContainer: UIView { return nil } + /// Boilerplate code. + required init?(coder: NSCoder) { + fatalError("Create `PopoverContainerView` programatically.") + } } From 8db831128c4413048a6f7d72c631b34d8f6b99af Mon Sep 17 00:00:00 2001 From: Zheng Date: Sun, 16 Jan 2022 21:34:35 -0800 Subject: [PATCH 2/4] Add VoiceOver support --- .../project.pbxproj | 4 ++ .../PopoversXcodeApp/Playground.swift | 4 ++ .../PlaygroundFiles/AccessibilityView.swift | 60 +++++++++++++++++++ Sources/Popover+Lifecycle.swift | 22 ++++++- Sources/Popover.swift | 40 +++++++++++++ Sources/PopoverContainerView.swift | 18 +++++- Sources/PopoverGestureContainer.swift | 6 +- Sources/PopoverWindows.swift | 2 +- Sources/SwiftUI/Readers.swift | 2 +- 9 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp.xcodeproj/project.pbxproj b/Examples/PopoversXcodeApp/PopoversXcodeApp.xcodeproj/project.pbxproj index 9a5d8a3..d5f661d 100644 --- a/Examples/PopoversXcodeApp/PopoversXcodeApp.xcodeproj/project.pbxproj +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3CA34FAC279533E300AC36DF /* AccessibilityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA34FAB279533E300AC36DF /* AccessibilityView.swift */; }; 3CBD875E27755E45005BBA48 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD875D27755E45005BBA48 /* App.swift */; }; 3CBD876527755E50005BBA48 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD875F27755E50005BBA48 /* ContentView.swift */; }; 3CBD876627755E50005BBA48 /* UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD876027755E50005BBA48 /* UIKit.swift */; }; @@ -44,6 +45,7 @@ /* Begin PBXFileReference section */ 3C6C745127822EE600E039F0 /* Popovers */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Popovers; path = ../..; sourceTree = ""; }; + 3CA34FAB279533E300AC36DF /* AccessibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityView.swift; sourceTree = ""; }; 3CBD874C27755E19005BBA48 /* PopoversXcodeApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PopoversXcodeApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3CBD875D27755E45005BBA48 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 3CBD875F27755E50005BBA48 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -133,6 +135,7 @@ 3CBD876F27755E57005BBA48 /* NestedView.swift */, 3CBD877027755E57005BBA48 /* SelectionView.swift */, 3CBD877127755E57005BBA48 /* BasicView.swift */, + 3CA34FAB279533E300AC36DF /* AccessibilityView.swift */, 3CBD877227755E57005BBA48 /* BackgroundView.swift */, 3CBD877327755E57005BBA48 /* RelativePositioningView.swift */, 3CBD877427755E57005BBA48 /* PopoverReaderView.swift */, @@ -269,6 +272,7 @@ 3CBD876627755E50005BBA48 /* UIKit.swift in Sources */, 3CBD877E27755E57005BBA48 /* BackgroundView.swift in Sources */, 3CBD876A27755E50005BBA48 /* Showroom.swift in Sources */, + 3CA34FAC279533E300AC36DF /* AccessibilityView.swift in Sources */, 3CBD877F27755E57005BBA48 /* RelativePositioningView.swift in Sources */, 3CBD876727755E50005BBA48 /* Utilities.swift in Sources */, 3CBD879027755E5B005BBA48 /* VideoView.swift in Sources */, diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift b/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift index 761c2be..5e2ad14 100644 --- a/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift @@ -36,6 +36,10 @@ struct Playground: View { NestedView() SelectionView() } + + Group { + AccessibilityView() + } } } } diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift new file mode 100644 index 0000000..213d836 --- /dev/null +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift @@ -0,0 +1,60 @@ +// +// AccessibilityView.swift +// PopoversXcodeApp +// +// Created by A. Zheng (github.com/aheze) on 1/16/22. +// Copyright © 2022 A. Zheng. All rights reserved. +// + +import Popovers +import SwiftUI + +struct AccessibilityView: View { + @State var present = false + + var body: some View { + ExampleRow( + image: "hand.point.up.braille", + title: "Accessibility", + color: 0x0021FF + ) { + present.toggle() + } + .popover( + present: $present, + attributes: { + $0.accessibility.shiftFocus = false + $0.accessibility.dismissButtonLabel = AnyView( + Text("Tap me to dismiss!") + .foregroundColor(.white) + .padding() + .background(Color.black.opacity(0.5)) + .cornerRadius(16) + ) + } + ) { + VStack { + VStack(alignment: .leading) { + Text("Popovers has full VoiceOver support!") + + HStack { + ExampleImage("speaker.wave.2", color: 0x0021FF) + + Text("By default, VoiceOver will read out the popover when it's presented. You can change this with `attributes.accessibility.shiftFocus`.") + } + + HStack { + ExampleImage("hand.thumbsup", color: 0x0021FF) + + Text("By default, a \(Image(systemName: "xmark.circle.fill")) button will appear next to popovers when VoiceOver is on. You can customize this with `attributes.accessibility.dismissButtonLabel`.") + } + } + } + .padding() + .background(.background) + .cornerRadius(12) + .shadow(radius: 1) + .frame(maxWidth: 300) + } + } +} diff --git a/Sources/Popover+Lifecycle.swift b/Sources/Popover+Lifecycle.swift index 4ee88c4..9eeca8e 100644 --- a/Sources/Popover+Lifecycle.swift +++ b/Sources/Popover+Lifecycle.swift @@ -28,9 +28,20 @@ public extension Popover { /** Add the popover to the container view. */ - let displayPopover = { + func displayPopover(in container: PopoverGestureContainer) { withTransaction(transaction) { model.add(self) + + /// Stop VoiceOver from reading out background views if `blocksBackgroundTouches` is true. + if attributes.blocksBackgroundTouches { + container.accessibilityViewIsModal = true + } + + + /// Shift VoiceOver focus to the popover. + if attributes.accessibility.shiftFocus { + UIAccessibility.post(notification: .screenChanged, argument: nil) + } } } @@ -40,7 +51,7 @@ public extension Popover { container = existingContainer /// The container is already laid out in the window, so we can go ahead and show the popover. - displayPopover() + displayPopover(in: container) } else { container = PopoverGestureContainer(frame: window.bounds) @@ -48,7 +59,9 @@ public extension Popover { Wait until the container is present in the view hierarchy before showing the popover, otherwise all the layout math will be working with wonky frames. */ - container.onMovedToWindow = displayPopover + container.onMovedToWindow = { + displayPopover(in: container) + } window.addSubview(container) } @@ -80,6 +93,9 @@ public extension Popover { context.presentedPopoverContainer?.removeFromSuperview() context.presentedPopoverContainer = nil } + + /// If at least one popover has `blocksBackgroundTouches` set to true, stop VoiceOver from reading out background views + context.presentedPopoverContainer?.accessibilityViewIsModal = model.popovers.contains { $0.attributes.blocksBackgroundTouches } } /// Remove this popover from the view model, dismissing it. diff --git a/Sources/Popover.swift b/Sources/Popover.swift index e31411c..f26478f 100644 --- a/Sources/Popover.swift +++ b/Sources/Popover.swift @@ -102,6 +102,9 @@ public struct Popover: Identifiable { /// Prevent views underneath the popover from being pressed. public var blocksBackgroundTouches = false + + /// Stores accessibility values. + public var accessibility = Accessibility() /// Called when the user taps outside the popover. public var onTapOutside: (() -> Void)? @@ -125,6 +128,7 @@ public struct Popover: Identifiable { dismissal: Popover.Attributes.Dismissal = Dismissal(), rubberBandingMode: Popover.Attributes.RubberBandingMode = [.xAxis, .yAxis], blocksBackgroundTouches: Bool = false, + accessibility: Accessibility = Accessibility(), onTapOutside: (() -> Void)? = nil, onDismiss: (() -> Void)? = nil, onContextChange: ((Popover.Context) -> Void)? = nil @@ -138,6 +142,7 @@ public struct Popover: Identifiable { self.dismissal = dismissal self.rubberBandingMode = rubberBandingMode self.blocksBackgroundTouches = blocksBackgroundTouches + self.accessibility = accessibility self.onTapOutside = onTapOutside self.onDismiss = onDismiss self.onContextChange = onContextChange @@ -329,6 +334,41 @@ public struct Popover: Identifiable { public static let none = Mode([]) } } + + /// Define VoiceOver behavior. + public struct Accessibility { + + /// Focus the popover when presented. + public var shiftFocus = true + + /** + A view that's only shown when VoiceOver is running. Dismisses the popover when tapped. + + Tap-outside-to-dismiss is unsupported in VoiceOver, so this provides an alternate method for dismissal. + */ + public var dismissButtonLabel: AnyView? = defaultDismissButtonLabel + + + /// Create the default VoiceOver behavior for the popover. + public init( + shiftFocus: Bool = true, + dismissButtonLabel: (() -> AnyView)? = { defaultDismissButtonLabel } + ) { + self.shiftFocus = shiftFocus + self.dismissButtonLabel = dismissButtonLabel?() + } + + /// The default voiceover dismiss button view, an X + public static let defaultDismissButtonLabel: AnyView = .init( + AnyView( + Image(systemName: "xmark") + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Color.black.opacity(0.25)) + .cornerRadius(18) + ) + ) + } } /** diff --git a/Sources/PopoverContainerView.swift b/Sources/PopoverContainerView.swift index 8392d7a..7af7328 100644 --- a/Sources/PopoverContainerView.swift +++ b/Sources/PopoverContainerView.swift @@ -31,12 +31,24 @@ struct PopoverContainerView: View { popover.background /// Show the popover's main content view. - popover - .view + HStack(alignment: .top) { + popover.view + + /// Have VoiceOver read the popover view first, before the dismiss button. + .accessibility(sortPriority: 1) + + /// If a `dismissButtonLabel` was set, show it. + if let dismissButtonLabel = popover.attributes.accessibility.dismissButtonLabel { + Button { + popover.dismiss() + } label: { + dismissButtonLabel + } + } + } .onDisappear { popover.context.onDisappear?() } - /// Hide the popover until its size has been calculated. .opacity(popover.context.size != nil ? 1 : 0) diff --git a/Sources/PopoverGestureContainer.swift b/Sources/PopoverGestureContainer.swift index cc5ac1b..f336e3c 100644 --- a/Sources/PopoverGestureContainer.swift +++ b/Sources/PopoverGestureContainer.swift @@ -20,6 +20,7 @@ class PopoverGestureContainer: UIView { /// Allow resizing. self.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } override func layoutSubviews() { @@ -32,8 +33,8 @@ class PopoverGestureContainer: UIView { override func didMoveToWindow() { super.didMoveToWindow() + /// There might not be a window yet, but that's fine. Just wait until there's actually a window. guard let window = window else { - print("[Popovers] - `PopoverGestureContainer` does not have a parent window. Please file a bug report (https://github.com/aheze/Popovers/issues).") return } @@ -62,6 +63,7 @@ class PopoverGestureContainer: UIView { The popover container view takes up the entire screen, so normally it would block all touches from going through. This method fixes that. */ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + /// Make sure the hit event was actually a touch and not a cursor hover or something else. guard event.map({ $0.type == .touches }) ?? true else { return nil } @@ -129,6 +131,6 @@ class PopoverGestureContainer: UIView { /// Boilerplate code. required init?(coder: NSCoder) { - fatalError("Create `PopoverContainerView` programatically.") + fatalError("[Popovers] - Create this view programmatically.") } } diff --git a/Sources/PopoverWindows.swift b/Sources/PopoverWindows.swift index a503cea..c0371c0 100644 --- a/Sources/PopoverWindows.swift +++ b/Sources/PopoverWindows.swift @@ -111,7 +111,7 @@ extension UIResponder { return viewController.view.popoverModel } - fatalError("No `PopoverModel` present in responder chain (\(self)) - has the source view been installed into a window?") + fatalError("[Popovers] - No `PopoverModel` present in responder chain (\(self)) - has the source view been installed into a window?") } } diff --git a/Sources/SwiftUI/Readers.swift b/Sources/SwiftUI/Readers.swift index 8dae8af..d75bc30 100644 --- a/Sources/SwiftUI/Readers.swift +++ b/Sources/SwiftUI/Readers.swift @@ -88,7 +88,7 @@ public struct WindowReader: View { @available(*, unavailable) required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") + fatalError("[Popovers] - Create this view programmatically.") } override func didMoveToWindow() { From fae01671b65ae88486958fdb42b79e16ffa70a33 Mon Sep 17 00:00:00 2001 From: Zheng Date: Sun, 16 Jan 2022 21:37:44 -0800 Subject: [PATCH 3/4] Clean up code, ensure context change is called after it actually changes --- .../Package.swift | 16 +- .../PopoversXcodeApp/Playground.swift | 2 +- .../PlaygroundFiles/AccessibilityView.swift | 16 +- Package.swift | 4 +- Sources/Popover+Lifecycle.swift | 37 ++--- Sources/Popover.swift | 16 +- Sources/PopoverContainerView.swift | 146 +++++++++--------- Sources/PopoverGestureContainer.swift | 30 ++-- Sources/PopoverModel.swift | 4 +- Sources/PopoverWindows.swift | 1 - 10 files changed, 133 insertions(+), 139 deletions(-) diff --git a/Examples/PopoversPlaygroundsApp.swiftpm/Package.swift b/Examples/PopoversPlaygroundsApp.swiftpm/Package.swift index 42997c3..d8c32e8 100644 --- a/Examples/PopoversPlaygroundsApp.swiftpm/Package.swift +++ b/Examples/PopoversPlaygroundsApp.swiftpm/Package.swift @@ -4,13 +4,13 @@ // This file is automatically generated. // Do not edit it by hand because the contents will be replaced. -import PackageDescription import AppleProductTypes +import PackageDescription let package = Package( name: "PopoversPlaygroundsApp", platforms: [ - .iOS("15.2") + .iOS("15.2"), ], products: [ .iOSApplication( @@ -24,26 +24,26 @@ let package = Package( accentColorAssetName: "AccentColor", supportedDeviceFamilies: [ .pad, - .phone + .phone, ], supportedInterfaceOrientations: [ .portrait, .landscapeRight, .landscapeLeft, - .portraitUpsideDown(.when(deviceFamilies: [.pad])) + .portraitUpsideDown(.when(deviceFamilies: [.pad])), ] - ) + ), ], dependencies: [ - .package(url: "https://github.com/aheze/Popovers", "1.0.0"..<"2.0.0") + .package(url: "https://github.com/aheze/Popovers", "1.0.0" ..< "2.0.0"), ], targets: [ .executableTarget( name: "AppModule", dependencies: [ - .product(name: "Popovers", package: "popovers") + .product(name: "Popovers", package: "popovers"), ], path: "." - ) + ), ] ) diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift b/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift index 5e2ad14..dd9bbff 100644 --- a/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp/Playground.swift @@ -36,7 +36,7 @@ struct Playground: View { NestedView() SelectionView() } - + Group { AccessibilityView() } diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift index 213d836..201d3d0 100644 --- a/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift @@ -39,22 +39,22 @@ struct AccessibilityView: View { HStack { ExampleImage("speaker.wave.2", color: 0x0021FF) - + Text("By default, VoiceOver will read out the popover when it's presented. You can change this with `attributes.accessibility.shiftFocus`.") } - + HStack { ExampleImage("hand.thumbsup", color: 0x0021FF) - + Text("By default, a \(Image(systemName: "xmark.circle.fill")) button will appear next to popovers when VoiceOver is on. You can customize this with `attributes.accessibility.dismissButtonLabel`.") } } } - .padding() - .background(.background) - .cornerRadius(12) - .shadow(radius: 1) - .frame(maxWidth: 300) + .padding() + .background(.background) + .cornerRadius(12) + .shadow(radius: 1) + .frame(maxWidth: 300) } } } diff --git a/Package.swift b/Package.swift index 3cc828c..a59df5c 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Popovers", platforms: [ - .iOS(.v13) + .iOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -26,6 +26,6 @@ let package = Package( name: "Popovers", dependencies: [], path: "Sources" - ) + ), ] ) diff --git a/Sources/Popover+Lifecycle.swift b/Sources/Popover+Lifecycle.swift index 9eeca8e..0ade76a 100644 --- a/Sources/Popover+Lifecycle.swift +++ b/Sources/Popover+Lifecycle.swift @@ -21,40 +21,39 @@ public extension Popover { /// Inject the transaction into the popover, so following frame calculations are animated smoothly. context.transaction = transaction - + /// Get the popover model that's tied to the window. let model = window.popoverModel - + /** Add the popover to the container view. */ func displayPopover(in container: PopoverGestureContainer) { withTransaction(transaction) { model.add(self) - + /// Stop VoiceOver from reading out background views if `blocksBackgroundTouches` is true. if attributes.blocksBackgroundTouches { container.accessibilityViewIsModal = true } - - + /// Shift VoiceOver focus to the popover. if attributes.accessibility.shiftFocus { UIAccessibility.post(notification: .screenChanged, argument: nil) } } } - + /// Find the existing container view for popovers in this window. If it does not exist, we need to insert one. let container: PopoverGestureContainer if let existingContainer = window.popoverContainerView { container = existingContainer - + /// The container is already laid out in the window, so we can go ahead and show the popover. displayPopover(in: container) } else { container = PopoverGestureContainer(frame: window.bounds) - + /** Wait until the container is present in the view hierarchy before showing the popover, otherwise all the layout math will be working with wonky frames. @@ -62,10 +61,10 @@ public extension Popover { container.onMovedToWindow = { displayPopover(in: container) } - + window.addSubview(container) } - + /// Hang on to the container for future dismiss/replace actions. context.presentedPopoverContainer = container } @@ -93,7 +92,7 @@ public extension Popover { context.presentedPopoverContainer?.removeFromSuperview() context.presentedPopoverContainer = nil } - + /// If at least one popover has `blocksBackgroundTouches` set to true, stop VoiceOver from reading out background views context.presentedPopoverContainer?.accessibilityViewIsModal = model.popovers.contains { $0.attributes.blocksBackgroundTouches } } @@ -140,28 +139,27 @@ public extension Popover { } } -extension UIResponder { +public extension UIResponder { /// Replace a popover with another popover. Convenience method for `Popover.replace(with:)`. - public func replace(_ oldPopover: Popover, with newPopover: Popover) { + func replace(_ oldPopover: Popover, with newPopover: Popover) { oldPopover.replace(with: newPopover) } - + /// Dismiss a popover. Convenience method for `Popover.dismiss(transaction:)`. - public func dismiss(_ popover: Popover) { + func dismiss(_ popover: Popover) { popover.dismiss() } } -extension UIViewController { +public extension UIViewController { /// Present a `Popover` using this `UIViewController` as its presentation context. - public func present(_ popover: Popover) { + func present(_ popover: Popover) { guard let window = view.window else { return } popover.present(in: window) } } extension UIView { - var popoverContainerView: PopoverGestureContainer? { if let container = self as? PopoverGestureContainer { return container @@ -171,9 +169,8 @@ extension UIView { return container } } - + return nil } } - } diff --git a/Sources/Popover.swift b/Sources/Popover.swift index f26478f..7eeabea 100644 --- a/Sources/Popover.swift +++ b/Sources/Popover.swift @@ -102,7 +102,7 @@ public struct Popover: Identifiable { /// Prevent views underneath the popover from being pressed. public var blocksBackgroundTouches = false - + /// Stores accessibility values. public var accessibility = Accessibility() @@ -334,21 +334,19 @@ public struct Popover: Identifiable { public static let none = Mode([]) } } - + /// Define VoiceOver behavior. public struct Accessibility { - /// Focus the popover when presented. public var shiftFocus = true - + /** A view that's only shown when VoiceOver is running. Dismisses the popover when tapped. - + Tap-outside-to-dismiss is unsupported in VoiceOver, so this provides an alternate method for dismissal. */ public var dismissButtonLabel: AnyView? = defaultDismissButtonLabel - /// Create the default VoiceOver behavior for the popover. public init( shiftFocus: Bool = true, @@ -357,7 +355,7 @@ public struct Popover: Identifiable { self.shiftFocus = shiftFocus self.dismissButtonLabel = dismissButtonLabel?() } - + /// The default voiceover dismiss button view, an X public static let defaultDismissButtonLabel: AnyView = .init( AnyView( @@ -447,7 +445,9 @@ public struct Popover: Identifiable { public init() { changeSink = objectWillChange.sink { [weak self] in guard let self = self else { return } - self.attributes.onContextChange?(self) + DispatchQueue.main.async { + self.attributes.onContextChange?(self) + } } } } diff --git a/Sources/PopoverContainerView.swift b/Sources/PopoverContainerView.swift index 7af7328..6363b9b 100644 --- a/Sources/PopoverContainerView.swift +++ b/Sources/PopoverContainerView.swift @@ -33,10 +33,10 @@ struct PopoverContainerView: View { /// Show the popover's main content view. HStack(alignment: .top) { popover.view - - /// Have VoiceOver read the popover view first, before the dismiss button. + + /// Have VoiceOver read the popover view first, before the dismiss button. .accessibility(sortPriority: 1) - + /// If a `dismissButtonLabel` was set, show it. if let dismissButtonLabel = popover.attributes.accessibility.dismissButtonLabel { Button { @@ -46,91 +46,91 @@ struct PopoverContainerView: View { } } } - .onDisappear { - popover.context.onDisappear?() - } - /// Hide the popover until its size has been calculated. - .opacity(popover.context.size != nil ? 1 : 0) - - /// Apply the presentation and dismissal transitions. - .transition( - .asymmetric( - insertion: popover.attributes.presentation.transition ?? .opacity, - removal: popover.attributes.dismissal.transition ?? .opacity - ) + .onDisappear { + popover.context.onDisappear?() + } + /// Hide the popover until its size has been calculated. + .opacity(popover.context.size != nil ? 1 : 0) + + /// Apply the presentation and dismissal transitions. + .transition( + .asymmetric( + insertion: popover.attributes.presentation.transition ?? .opacity, + removal: popover.attributes.dismissal.transition ?? .opacity ) - - /// Read the popover's size in the view. - .sizeReader { size in - if let transaction = popover.context.transaction { - /// When `popover.context.size` is nil, the popover was just presented. - if popover.context.size == nil { + ) + + /// Read the popover's size in the view. + .sizeReader { size in + if let transaction = popover.context.transaction { + /// When `popover.context.size` is nil, the popover was just presented. + if popover.context.size == nil { + popover.setSize(size) + popoverModel.refresh(with: transaction) + } else { + /// Otherwise, the popover is *replacing* a previous popover, so animate it. + withTransaction(transaction) { popover.setSize(size) popoverModel.refresh(with: transaction) - } else { - /// Otherwise, the popover is *replacing* a previous popover, so animate it. - withTransaction(transaction) { - popover.setSize(size) - popoverModel.refresh(with: transaction) - } } - popover.context.transaction = nil } + popover.context.transaction = nil } + } - /// Offset the popover by the gesture's translation, if this current popover is the selected one. - .offset(popoverOffset(for: popover)) + /// Offset the popover by the gesture's translation, if this current popover is the selected one. + .offset(popoverOffset(for: popover)) - /// Add the drag gesture. - .simultaneousGesture( - /// `minimumDistance: 2` is enough to allow scroll views to scroll, if one is contained in the popover. - DragGesture(minimumDistance: 2) - .onChanged { value in + /// Add the drag gesture. + .simultaneousGesture( + /// `minimumDistance: 2` is enough to allow scroll views to scroll, if one is contained in the popover. + DragGesture(minimumDistance: 2) + .onChanged { value in - /// Select the popover for dragging. - if selectedPopover == nil { - DispatchQueue.main.async { - selectedPopover = popover - } + /// Select the popover for dragging. + if selectedPopover == nil { + DispatchQueue.main.async { + selectedPopover = popover } - - /// Apply the offset. - applyDraggingOffset(popover: popover, translation: value.translation) - - /// Update the visual frame to account for the dragging offset. - popover.context.frame = CGRect( - origin: popover.context.staticFrame.origin + CGPoint( - x: selectedPopoverOffset.width, - y: selectedPopoverOffset.height - ), - size: popover.context.size ?? .zero - ) } - .onEnded { value in - /// The expected dragging end point. - let finalOrigin = CGPoint( - x: popover.context.staticFrame.origin.x + value.predictedEndTranslation.width, - y: popover.context.staticFrame.origin.y + value.predictedEndTranslation.height - ) + /// Apply the offset. + applyDraggingOffset(popover: popover, translation: value.translation) + + /// Update the visual frame to account for the dragging offset. + popover.context.frame = CGRect( + origin: popover.context.staticFrame.origin + CGPoint( + x: selectedPopoverOffset.width, + y: selectedPopoverOffset.height + ), + size: popover.context.size ?? .zero + ) + } + .onEnded { value in - /// Recalculate the popover's frame. - withAnimation(.spring()) { - selectedPopoverOffset = .zero + /// The expected dragging end point. + let finalOrigin = CGPoint( + x: popover.context.staticFrame.origin.x + value.predictedEndTranslation.width, + y: popover.context.staticFrame.origin.y + value.predictedEndTranslation.height + ) - /// Let the popover know that it finished dragging. - popover.positionChanged(to: finalOrigin) - popover.context.frame = popover.context.staticFrame - } + /// Recalculate the popover's frame. + withAnimation(.spring()) { + selectedPopoverOffset = .zero - /// Unselect the popover. - self.selectedPopover = nil - }, - including: popover.attributes.rubberBandingMode.isEmpty - ? .subviews /// Stop gesture and only allow those in the popover's view if dragging is not enabled. - : (popoverModel.popoversDraggable ? .all : .subviews) /// Otherwise, allow dragging - but also check if `popoversDraggable` is true first. - ) - .padding(edgeInsets(for: popover)) /// Apply edge padding so that the popover doesn't overflow off the screen. + /// Let the popover know that it finished dragging. + popover.positionChanged(to: finalOrigin) + popover.context.frame = popover.context.staticFrame + } + + /// Unselect the popover. + self.selectedPopover = nil + }, + including: popover.attributes.rubberBandingMode.isEmpty + ? .subviews /// Stop gesture and only allow those in the popover's view if dragging is not enabled. + : (popoverModel.popoversDraggable ? .all : .subviews) /// Otherwise, allow dragging - but also check if `popoversDraggable` is true first. + ) + .padding(edgeInsets(for: popover)) /// Apply edge padding so that the popover doesn't overflow off the screen. } } .edgesIgnoringSafeArea(.all) /// All calculations are done from the screen bounds. diff --git a/Sources/PopoverGestureContainer.swift b/Sources/PopoverGestureContainer.swift index f336e3c..44c5fc5 100644 --- a/Sources/PopoverGestureContainer.swift +++ b/Sources/PopoverGestureContainer.swift @@ -10,49 +10,47 @@ import SwiftUI /// A hosting view for `PopoverContainerView` with tap filtering. class PopoverGestureContainer: UIView { - /// A closure to be invoked when this view is inserted into a window's view hierarchy. var onMovedToWindow: (() -> Void)? /// Create a new `PopoverGestureContainer`. override init(frame: CGRect) { super.init(frame: frame) - + /// Allow resizing. - self.autoresizingMask = [.flexibleWidth, .flexibleHeight] - + autoresizingMask = [.flexibleWidth, .flexibleHeight] } - + override func layoutSubviews() { super.layoutSubviews() - + /// Orientation or screen bounds changed. Update popover frames. popoverModel.updateFrames() } - + override func didMoveToWindow() { super.didMoveToWindow() - + /// There might not be a window yet, but that's fine. Just wait until there's actually a window. guard let window = window else { return } - + /// Create the SwiftUI view that contains all the popovers. let popoverContainerView = PopoverContainerView(popoverModel: popoverModel) .environment(\.window, window) /// Inject the window. - + let hostingController = UIHostingController(rootView: popoverContainerView) hostingController.view.frame = bounds hostingController.view.backgroundColor = .clear hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - + addSubview(hostingController.view) - + /// Ensure the view is laid out so that SwiftUI animations don't stutter. setNeedsLayout() layoutIfNeeded() - + /// Let the presenter know that its window is available. onMovedToWindow?() } @@ -63,7 +61,6 @@ class PopoverGestureContainer: UIView { The popover container view takes up the entire screen, so normally it would block all touches from going through. This method fixes that. */ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - /// Make sure the hit event was actually a touch and not a cursor hover or something else. guard event.map({ $0.type == .touches }) ?? true else { return nil } @@ -128,9 +125,10 @@ class PopoverGestureContainer: UIView { /// The touch did not hit any popover, so pass it through to the hit testing target. return nil } - + /// Boilerplate code. - required init?(coder: NSCoder) { + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError("[Popovers] - Create this view programmatically.") } } diff --git a/Sources/PopoverModel.swift b/Sources/PopoverModel.swift index bcb80be..231bce1 100644 --- a/Sources/PopoverModel.swift +++ b/Sources/PopoverModel.swift @@ -34,7 +34,7 @@ class PopoverModel: ObservableObject { /// Force the container view to update. func update() { - self.objectWillChange.send() + objectWillChange.send() } /** @@ -56,7 +56,7 @@ class PopoverModel: ObservableObject { func add(_ popover: Popover) { popovers.append(popover) } - + /// Removes a `Popover` from this model. func remove(_ popover: Popover) { popovers.removeAll { candidate in diff --git a/Sources/PopoverWindows.swift b/Sources/PopoverWindows.swift index c0371c0..4500091 100644 --- a/Sources/PopoverWindows.swift +++ b/Sources/PopoverWindows.swift @@ -115,7 +115,6 @@ extension UIResponder { } } - public extension UIResponder { /** Get a currently-presented popover with a tag. Returns `nil` if no popover with the tag was found. From 6027bc6483622da4ab1b232a50c8416c1e980909 Mon Sep 17 00:00:00 2001 From: Zheng Date: Sun, 16 Jan 2022 21:44:08 -0800 Subject: [PATCH 4/4] Add tip in accessibility view example --- .../PlaygroundFiles/AccessibilityView.swift | 8 +++++++- Sources/PopoverContainerView.swift | 7 +++++-- Sources/PopoverGestureContainer.swift | 6 ++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift index 201d3d0..c4c8ab4 100644 --- a/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift +++ b/Examples/PopoversXcodeApp/PopoversXcodeApp/PlaygroundFiles/AccessibilityView.swift @@ -48,13 +48,19 @@ struct AccessibilityView: View { Text("By default, a \(Image(systemName: "xmark.circle.fill")) button will appear next to popovers when VoiceOver is on. You can customize this with `attributes.accessibility.dismissButtonLabel`.") } + + HStack { + ExampleImage.tip + + Text("If you already have a button that sets `present` to `false`, remove the default dismiss button with `dismissButtonLabel = nil`.") + } } } .padding() .background(.background) .cornerRadius(12) .shadow(radius: 1) - .frame(maxWidth: 300) + .frame(maxWidth: 500) } } } diff --git a/Sources/PopoverContainerView.swift b/Sources/PopoverContainerView.swift index 6363b9b..4b23b48 100644 --- a/Sources/PopoverContainerView.swift +++ b/Sources/PopoverContainerView.swift @@ -37,8 +37,11 @@ struct PopoverContainerView: View { /// Have VoiceOver read the popover view first, before the dismiss button. .accessibility(sortPriority: 1) - /// If a `dismissButtonLabel` was set, show it. - if let dismissButtonLabel = popover.attributes.accessibility.dismissButtonLabel { + /// If VoiceOver is on and a `dismissButtonLabel` was set, show it. + if + UIAccessibility.isVoiceOverRunning, + let dismissButtonLabel = popover.attributes.accessibility.dismissButtonLabel + { Button { popover.dismiss() } label: { diff --git a/Sources/PopoverGestureContainer.swift b/Sources/PopoverGestureContainer.swift index 44c5fc5..d567ae2 100644 --- a/Sources/PopoverGestureContainer.swift +++ b/Sources/PopoverGestureContainer.swift @@ -24,7 +24,7 @@ class PopoverGestureContainer: UIView { override func layoutSubviews() { super.layoutSubviews() - /// Orientation or screen bounds changed. Update popover frames. + /// Orientation or screen bounds changed, so update popover frames. popoverModel.updateFrames() } @@ -32,9 +32,7 @@ class PopoverGestureContainer: UIView { super.didMoveToWindow() /// There might not be a window yet, but that's fine. Just wait until there's actually a window. - guard let window = window else { - return - } + guard let window = window else { return } /// Create the SwiftUI view that contains all the popovers. let popoverContainerView = PopoverContainerView(popoverModel: popoverModel)