├── README.md
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.resolved
├── Package.swift
├── LICENSE
└── Sources
└── Popover
├── TouchObserveGesture.swift
├── PopoverDismissGesture.swift
├── Popover.swift
├── PopoverConfig.swift
└── PopoverManager.swift
/README.md:
--------------------------------------------------------------------------------
1 | # Popover
2 |
3 | A description of this package.
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "BaseToolbox",
6 | "repositoryURL": "https://github.com/lkzhao/BaseToolbox",
7 | "state": {
8 | "branch": null,
9 | "revision": "c2d437c0ce096d8d438c5fea38c6d92ee3dea020",
10 | "version": "0.1.5"
11 | }
12 | },
13 | {
14 | "package": "KeyboardManager",
15 | "repositoryURL": "https://github.com/lkzhao/KeyboardManager",
16 | "state": {
17 | "branch": null,
18 | "revision": "0e2c224c775ffcd42fcf9e1d386edb22043d4264",
19 | "version": "0.0.1"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Popover",
8 | platforms: [
9 | .iOS("13.0")
10 | ],
11 | products: [
12 | // Products define the executables and libraries a package produces, and make them visible to other packages.
13 | .library(
14 | name: "Popover",
15 | targets: ["Popover"]),
16 | ],
17 | dependencies: [
18 | .package(url: "https://github.com/lkzhao/BaseToolbox", from: "0.1.5"),
19 | .package(url: "https://github.com/lkzhao/KeyboardManager", from: "0.0.1"),
20 | ],
21 | targets: [
22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
23 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
24 | .target(
25 | name: "Popover",
26 | dependencies: ["BaseToolbox", "KeyboardManager"]),
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Luke Zhao
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/Popover/TouchObserveGesture.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | /// a gesture that notifies the target whenever a touch begans. It doesn't block touch or any other gestures
4 | public class TouchObserveGesture: UIGestureRecognizer {
5 |
6 | public override init(target: Any?, action: Selector?) {
7 | super.init(target: target, action: action)
8 | if #available(iOS 13.4, *) {
9 | allowedTouchTypes = [
10 | NSNumber(value: UITouch.TouchType.pencil.rawValue),
11 | NSNumber(value: UITouch.TouchType.direct.rawValue),
12 | NSNumber(value: UITouch.TouchType.indirect.rawValue),
13 | NSNumber(value: UITouch.TouchType.indirectPointer.rawValue),
14 | ]
15 | } else {
16 | allowedTouchTypes = [
17 | NSNumber(value: UITouch.TouchType.pencil.rawValue),
18 | NSNumber(value: UITouch.TouchType.direct.rawValue),
19 | NSNumber(value: UITouch.TouchType.indirect.rawValue),
20 | ]
21 | }
22 | delaysTouchesBegan = false
23 | delaysTouchesEnded = false
24 | cancelsTouchesInView = false
25 | delegate = self
26 | }
27 |
28 | public override func touchesBegan(_ touches: Set, with event: UIEvent) {
29 | super.touchesBegan(touches, with: event)
30 | state = .began
31 | }
32 |
33 | public override func touchesMoved(_ touches: Set, with event: UIEvent) {
34 | super.touchesMoved(touches, with: event)
35 | if state == .began {
36 | state = .changed
37 | }
38 | }
39 |
40 | public override func touchesEnded(_ touches: Set, with event: UIEvent) {
41 | super.touchesEnded(touches, with: event)
42 | if state == .began || state == .changed {
43 | state = .ended
44 | }
45 | }
46 |
47 | public override func touchesCancelled(_ touches: Set, with event: UIEvent) {
48 | super.touchesCancelled(touches, with: event)
49 | if state == .began || state == .changed {
50 | state = .cancelled
51 | }
52 | }
53 | }
54 |
55 | extension TouchObserveGesture: UIGestureRecognizerDelegate {
56 | public func gestureRecognizer(
57 | _ gestureRecognizer: UIGestureRecognizer,
58 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
59 | ) -> Bool {
60 | return true
61 | }
62 |
63 | public func gestureRecognizer(
64 | _ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
65 | ) -> Bool {
66 | return false
67 | }
68 |
69 | public func gestureRecognizer(
70 | _ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
71 | ) -> Bool {
72 | return false
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/Popover/PopoverDismissGesture.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | /// a gesture that notifies the target whenever a touch begans. It doesn't block touch or any other gestures
4 | public class PopoverDismissGesture: UIGestureRecognizer {
5 | var startPosition: CGPoint?
6 | var blockTap: Bool = true
7 |
8 | public override init(target: Any?, action: Selector?) {
9 | super.init(target: target, action: action)
10 | if #available(iOS 13.4, *) {
11 | allowedTouchTypes = [
12 | NSNumber(value: UITouch.TouchType.pencil.rawValue),
13 | NSNumber(value: UITouch.TouchType.direct.rawValue),
14 | NSNumber(value: UITouch.TouchType.indirect.rawValue),
15 | NSNumber(value: UITouch.TouchType.indirectPointer.rawValue),
16 | ]
17 | } else {
18 | allowedTouchTypes = [
19 | NSNumber(value: UITouch.TouchType.pencil.rawValue),
20 | NSNumber(value: UITouch.TouchType.direct.rawValue),
21 | NSNumber(value: UITouch.TouchType.indirect.rawValue),
22 | ]
23 | }
24 | delaysTouchesBegan = false
25 | delaysTouchesEnded = false
26 | cancelsTouchesInView = false
27 | delegate = self
28 | }
29 |
30 | public override func touchesBegan(_ touches: Set, with event: UIEvent) {
31 | super.touchesBegan(touches, with: event)
32 | startPosition = touches.first?.location(in: nil)
33 | }
34 |
35 | public override func touchesMoved(_ touches: Set, with event: UIEvent) {
36 | super.touchesMoved(touches, with: event)
37 | if let startPosition = startPosition, let currentPosition = touches.first?.location(in: nil), currentPosition.distance(startPosition) >= 10 {
38 | state = .recognized
39 | }
40 | }
41 |
42 | public override func touchesEnded(_ touches: Set, with event: UIEvent) {
43 | super.touchesEnded(touches, with: event)
44 | if state == .possible {
45 | state = .recognized
46 | }
47 | }
48 | }
49 |
50 | extension PopoverDismissGesture: UIGestureRecognizerDelegate {
51 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
52 | true
53 | }
54 |
55 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
56 | false
57 | }
58 |
59 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
60 | // we block tap gesture underneath the popover view
61 | if blockTap, otherGestureRecognizer is UITapGestureRecognizer ||
62 | "\(type(of: otherGestureRecognizer))" == "_UITouchDownGestureRecognizer" // this gesture is used for UIButton with menu
63 | {
64 | return otherGestureRecognizer.view?.closestViewMatchingType(PopoverView.self) == nil
65 | } else {
66 | return false
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/Popover/Popover.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UIKit
3 | import BaseToolbox
4 |
5 | internal class ShapeView: UIView {
6 | override class var layerClass: AnyClass {
7 | return CAShapeLayer.self
8 | }
9 |
10 | var shapeLayer: CAShapeLayer {
11 | return layer as! CAShapeLayer
12 | }
13 |
14 | var path: UIBezierPath? {
15 | didSet {
16 | shapeLayer.path = path?.cgPath
17 | }
18 | }
19 |
20 | var fillColor: UIColor? {
21 | didSet {
22 | shapeLayer.fillColor = fillColor?.cgColor
23 | }
24 | }
25 |
26 | var strokeColor: UIColor? {
27 | didSet {
28 | shapeLayer.strokeColor = strokeColor?.cgColor
29 | }
30 | }
31 |
32 | open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
33 | super.traitCollectionDidChange(previousTraitCollection)
34 | if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
35 | shapeLayer.fillColor = fillColor?.cgColor
36 | shapeLayer.strokeColor = strokeColor?.cgColor
37 | }
38 | }
39 | }
40 |
41 | public class PopoverView: UIView {
42 | public var identifier: String
43 | public var insets = UIEdgeInsets.zero
44 | var hideTimer: Timer?
45 | var entryTransform: CGAffineTransform = .identity
46 |
47 | let triangle = ShapeView()
48 |
49 | let contentWrapperView = UIView().then {
50 | $0.cornerRadius = 12
51 | $0.backgroundColor = .white
52 | $0.clipsToBounds = true
53 | }
54 |
55 | public internal(set) var contentView: UIView? {
56 | didSet {
57 | oldValue?.removeFromSuperview()
58 | if let contentView = contentView {
59 | contentWrapperView.addSubview(contentView)
60 | }
61 | }
62 | }
63 |
64 | public init(identifier: String) {
65 | self.identifier = identifier
66 | super.init(frame: .zero)
67 |
68 | contentWrapperView.cornerRadius = 12
69 | contentWrapperView.backgroundColor = .white
70 | triangle.fillColor = .white
71 | triangle.transform = CGAffineTransform.identity.rotated(by: CGFloat.pi / 4)
72 | triangle.bounds = CGRect(x: 0, y: 0, width: 20, height: 20)
73 |
74 | let radius: CGFloat = 2
75 | let path = UIBezierPath()
76 | path.move(to: CGPoint(x: 20, y: 10))
77 | path.addLine(to: CGPoint(x: 20, y: 20 - radius))
78 | path.addArc(
79 | withCenter: CGPoint(x: 20 - radius, y: 20 - radius), radius: radius, startAngle: 0, endAngle: CGFloat.pi / 2,
80 | clockwise: true)
81 | path.addLine(to: CGPoint(x: 10, y: 20))
82 | path.addArc(
83 | withCenter: CGPoint(x: 10, y: 10), radius: 10, startAngle: CGFloat.pi / 2, endAngle: 2 * CGFloat.pi,
84 | clockwise: true)
85 | triangle.path = path
86 | addSubview(triangle)
87 | addSubview(contentWrapperView)
88 | }
89 |
90 | required init?(coder: NSCoder) {
91 | fatalError("init(coder:) has not been implemented")
92 | }
93 |
94 |
95 | public override func layoutSubviews() {
96 | super.layoutSubviews()
97 | contentWrapperView.frame = bounds
98 | contentView?.frame = bounds.inset(by: insets)
99 | }
100 |
101 | public override func sizeThatFits(_ size: CGSize) -> CGSize {
102 | return contentView?.sizeThatFits(size) ?? .zero
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Sources/Popover/PopoverConfig.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | public struct PopoverConfig {
4 | public static var defaultBackgroundColor: UIColor = .tertiarySystemBackground
5 | public static var defaultBackgroundOverlayColor: UIColor = .black.withAlphaComponent(0.15)
6 | public static var defaultBorderColor: UIColor = .separator
7 | public static var defaultBorderWidth: CGFloat = 0
8 | public static var defaultCornerRadius: CGFloat = 16.0
9 | public static var defaultShadowColor: UIColor = .black
10 | public static var defaultShadowOpacity: CGFloat = 0.3
11 | public static var defaultShadowRadius: CGFloat = 2
12 | public static var defaultShadowOffset: CGSize = CGSize(width: 0, height: 2)
13 | public static var defaultContainer: UIView? = UIApplication.shared.windows.first(where: { $0.isKeyWindow })
14 |
15 | public enum PopoverAlignment {
16 | case before, start, center, end, after
17 | }
18 | public enum PopoverAnchor {
19 | case frame(rect: CGRect)
20 | case view(view: UIView)
21 | }
22 | public enum PopoverTransitionType {
23 | case tooltip, notification
24 | }
25 | public struct PopoverPositioning {
26 | public var spacing: CGSize = .zero
27 | public var horizontalAlignment: PopoverAlignment = .center
28 | public var verticalAlignment: PopoverAlignment = .before
29 | public init() {}
30 | }
31 | public var container: UIView
32 | public var duration: TimeInterval = .infinity
33 | public var delay: TimeInterval = 0
34 | public var identifier: String? = nil
35 | public var insets: UIEdgeInsets = .zero
36 | public var containerInsets: UIEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
37 | public var transitionType: PopoverTransitionType = .tooltip
38 | public var appearingAnimationDuration: CGFloat = 0.4
39 | public var appearingAnimationSpringDamping: CGFloat = 0.8
40 | public var appearingAnimationInitialSpringVelocity: CGFloat = 0.0
41 | public var disappearingAnimationDuration: CGFloat = 0.28
42 |
43 | public var backgroundColor: UIColor = PopoverConfig.defaultBackgroundColor
44 | public var backgroundOverlayColor: UIColor = PopoverConfig.defaultBackgroundOverlayColor
45 | public var cornerRadius: CGFloat = PopoverConfig.defaultCornerRadius
46 | public var borderColor: UIColor = PopoverConfig.defaultBorderColor
47 | public var borderWidth: CGFloat = PopoverConfig.defaultBorderWidth
48 | public var shadowColor: UIColor = PopoverConfig.defaultShadowColor
49 | public var shadowOpacity: CGFloat = PopoverConfig.defaultShadowOpacity
50 | public var shadowRadius: CGFloat = PopoverConfig.defaultShadowRadius
51 | public var shadowOffset: CGSize = PopoverConfig.defaultShadowOffset
52 | public var triangleColor: UIColor? = nil
53 |
54 | public var clipsToBounds: Bool = true
55 | public var dismissPreviousPopover: Bool = true
56 | public var showBackgroundOverlay: Bool = true
57 | public var dismissByBackgroundTap: Bool = true
58 | public var shouldBlockBackgroundTapGesture: Bool = true
59 | public var showTriangle: Bool = true
60 | public var ignoreAnchorViewTransform: Bool = false
61 |
62 | public var anchor: PopoverAnchor = .frame(rect: CGRect(center: PopoverConfig.defaultContainer?.bounds.center ?? .zero, size: .zero))
63 |
64 | // block to be call when background tap is detected. return true if you want to dismiss the popover
65 | public var onBackgroundTap: ((UIGestureRecognizer) -> Bool)?
66 |
67 | public var onDismiss: (() -> Void)?
68 |
69 | public var sourceRect: CGRect {
70 | get {
71 | switch anchor {
72 | case let .frame(rect):
73 | return rect
74 | case let .view(view):
75 | if ignoreAnchorViewTransform {
76 | return container.convert(view.frameWithoutTransform, from: view.superview)
77 | } else {
78 | return container.convert(view.bounds, from: view)
79 | }
80 | }
81 | }
82 | set {
83 | anchor = .frame(rect: newValue)
84 | }
85 | }
86 |
87 | public var positioning = PopoverPositioning()
88 | public var transformPositioning: PopoverPositioning?
89 |
90 | public init(container: UIView) {
91 | self.container = container
92 | }
93 | }
94 |
95 | extension PopoverConfig {
96 | var isSourceRectValid: Bool {
97 | switch anchor {
98 | case .frame(let rect):
99 | return true
100 | case .view(let view):
101 | return container.superview != nil && view.superview != nil
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Sources/Popover/PopoverManager.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import BaseToolbox
3 | import KeyboardManager
4 |
5 | public struct PopoverData {
6 | public let view: PopoverView
7 | public let backgroundView: UIView?
8 | public let gesture: PopoverDismissGesture?
9 | public var config: PopoverConfig
10 | }
11 |
12 | public class PopoverManager: NSObject {
13 | public static let shared = PopoverManager()
14 |
15 | public private(set) var popovers: [PopoverData] = []
16 | public var currentPopover: PopoverView? {
17 | popovers.last?.view
18 | }
19 | public var currentPopoverConfig: PopoverConfig? {
20 | get {
21 | popovers.last?.config
22 | }
23 | set {
24 | if let newValue = newValue, !popovers.isEmpty {
25 | popovers[popovers.count - 1].config = newValue
26 | }
27 | }
28 | }
29 | public var currentBackgroundOverlay: UIView? {
30 | popovers.last?.backgroundView
31 | }
32 |
33 | public func show(
34 | popover: UIView,
35 | at: UIView,
36 | space: CGFloat = 10,
37 | showOnTop: Bool? = nil,
38 | showBackgroundOverlay: Bool = true,
39 | showTriangle: Bool = true,
40 | container: UIView? = nil
41 | ) {
42 | guard let container = container ?? at.window else { return }
43 | let maxSize = container.bounds.size.inset(by: UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5))
44 | let size = popover.sizeThatFits(maxSize)
45 | let shouldShowOnTop = container.convert(.zero, from: at).y > container.safeAreaInsets.top + 5 + size.height + space
46 | let showOnTop = showOnTop ?? shouldShowOnTop
47 | let position = CGPoint(x: at.bounds.midX, y: showOnTop ? -space : at.bounds.maxY + space)
48 | show(
49 | popover: popover,
50 | container: container,
51 | position: container.convert(position, from: at),
52 | showOnTop: showOnTop,
53 | showBackgroundOverlay: showBackgroundOverlay,
54 | showTriangle: showTriangle)
55 | }
56 |
57 | public func show(
58 | popover: UIView,
59 | duration: TimeInterval = .infinity,
60 | identifier: String? = nil,
61 | insets: UIEdgeInsets = .zero,
62 | backgroundColor: UIColor = PopoverConfig.defaultBackgroundColor,
63 | cornerRadius: CGFloat = PopoverConfig.defaultCornerRadius,
64 | container: UIView = PopoverConfig.defaultContainer!,
65 | position: CGPoint = PopoverConfig.defaultContainer!.bounds.center,
66 | showOnTop: Bool = true,
67 | showBackgroundOverlay: Bool = true,
68 | dismissByBackgroundTap: Bool = true,
69 | alignLeft: Bool = false,
70 | showTriangle: Bool = true
71 | ) {
72 | var config = PopoverConfig(container: container)
73 | config.duration = duration
74 | config.identifier = identifier
75 | config.insets = insets
76 | config.backgroundColor = backgroundColor
77 | config.cornerRadius = cornerRadius
78 | config.container = container
79 | config.showBackgroundOverlay = showBackgroundOverlay
80 | config.dismissByBackgroundTap = dismissByBackgroundTap
81 | config.showTriangle = showTriangle
82 | config.positioning.verticalAlignment = showOnTop ? .before : .after
83 | config.positioning.horizontalAlignment = alignLeft ? .start : .center
84 | config.sourceRect = CGRect(center: position, size: .zero)
85 | show(popover: popover, config: config)
86 | }
87 |
88 | @objc func didTouch(gr: PopoverDismissGesture) {
89 | if let lastPopover = popovers.last, lastPopover.gesture == gr {
90 | for popoverData in popovers.reversed() {
91 | if popoverData.view.point(inside: gr.location(in: popoverData.view), with: nil) {
92 | break
93 | }
94 | if !popoverData.config.dismissByBackgroundTap || !(popoverData.config.onBackgroundTap?(gr) ?? true) {
95 | break
96 | }
97 | let id = popoverData.view.identifier
98 | delay {
99 | PopoverManager.shared.hide(id: id)
100 | }
101 | if popoverData.config.shouldBlockBackgroundTapGesture {
102 | break
103 | }
104 | }
105 | }
106 | }
107 |
108 | public func show(popover: UIView, config: PopoverConfig) {
109 | if popovers.contains(where: { $0.view.identifier == config.identifier }) {
110 | // skip if identifier matches
111 | return
112 | }
113 |
114 | let container = config.container
115 |
116 | if config.dismissPreviousPopover {
117 | dismissAll()
118 | }
119 |
120 | let popoverWrapper = PopoverView(identifier: config.identifier ?? UUID().uuidString)
121 | popoverWrapper.contentWrapperView.cornerRadius = config.cornerRadius
122 | popoverWrapper.insets = config.insets
123 | popoverWrapper.contentView = popover
124 | popoverWrapper.zPosition = 10
125 | popoverWrapper.contentWrapperView.backgroundColor = config.backgroundColor
126 | popoverWrapper.contentWrapperView.clipsToBounds = config.clipsToBounds
127 | popoverWrapper.triangle.fillColor = config.triangleColor ?? config.backgroundColor
128 | popoverWrapper.shadowRadius = config.shadowRadius
129 | popoverWrapper.shadowOpacity = config.shadowOpacity
130 | popoverWrapper.shadowColor = config.shadowColor
131 | popoverWrapper.shadowOffset = config.shadowOffset
132 | popoverWrapper.entryTransform = self.layout(popover: popoverWrapper, config: config)
133 | popoverWrapper.transform = popoverWrapper.entryTransform
134 |
135 | var backgroundOverlay: UIView? = nil
136 | if config.showBackgroundOverlay {
137 | backgroundOverlay = UIView().then {
138 | $0.backgroundColor = config.backgroundOverlayColor
139 | }
140 | }
141 |
142 | let gesture = PopoverDismissGesture(target: self, action: #selector(didTouch(gr:)))
143 | gesture.blockTap = config.shouldBlockBackgroundTapGesture
144 | if config.dismissByBackgroundTap {
145 | container.addGestureRecognizer(gesture)
146 | }
147 |
148 | if !config.showTriangle {
149 | popoverWrapper.triangle.isHidden = true
150 | }
151 | if let backgroundOverlay = backgroundOverlay {
152 | backgroundOverlay.zPosition = 10
153 | backgroundOverlay.frame = container.bounds
154 | backgroundOverlay.alpha = 0
155 | container.addSubview(backgroundOverlay)
156 | }
157 | container.addSubview(popoverWrapper)
158 | popovers.append(PopoverData(view: popoverWrapper, backgroundView: backgroundOverlay, gesture: gesture, config: config))
159 | popoverWrapper.alpha = 0
160 | UIView.animate(
161 | withDuration: config.appearingAnimationDuration,
162 | delay: config.delay,
163 | usingSpringWithDamping: config.appearingAnimationSpringDamping,
164 | initialSpringVelocity: config.appearingAnimationInitialSpringVelocity,
165 | options: [.beginFromCurrentState, .allowUserInteraction],
166 | animations: {
167 | backgroundOverlay?.alpha = 1
168 | popoverWrapper.transform = .identity
169 | popoverWrapper.alpha = 1
170 | })
171 |
172 | if config.duration != .infinity {
173 | let id = popoverWrapper.identifier
174 | popoverWrapper.hideTimer = Timer.scheduledTimer(withTimeInterval: config.duration, repeats: false, block: { [weak self] _ in
175 | self?.hide(id: id)
176 | })
177 | }
178 | }
179 |
180 | func origin(positioning: PopoverConfig.PopoverPositioning, sourceRect: CGRect, size: CGSize, containerRect: CGRect)
181 | -> CGPoint
182 | {
183 | let spacing = positioning.spacing
184 | var popoverOrigin = CGPoint.zero
185 | switch positioning.horizontalAlignment {
186 | case .before:
187 | popoverOrigin.x = sourceRect.minX - size.width - spacing.width
188 | case .start:
189 | popoverOrigin.x = sourceRect.minX + spacing.width
190 | case .center:
191 | popoverOrigin.x = sourceRect.midX - size.width / 2
192 | case .end:
193 | popoverOrigin.x = sourceRect.maxX - size.width - spacing.width
194 | case .after:
195 | popoverOrigin.x = sourceRect.maxX + spacing.width
196 | }
197 | switch positioning.verticalAlignment {
198 | case .before:
199 | popoverOrigin.y = sourceRect.minY - size.height - spacing.height
200 | case .start:
201 | popoverOrigin.y = sourceRect.minY + spacing.height
202 | case .center:
203 | popoverOrigin.y = sourceRect.midY - size.height / 2
204 | case .end:
205 | popoverOrigin.y = sourceRect.maxY - size.height - spacing.height
206 | case .after:
207 | popoverOrigin.y = sourceRect.maxY + spacing.height
208 | }
209 | popoverOrigin.x = popoverOrigin.x.clamp(containerRect.minX, containerRect.maxX - size.width)
210 | popoverOrigin.y = popoverOrigin.y.clamp(containerRect.minY, containerRect.maxY - size.height)
211 | return popoverOrigin
212 | }
213 |
214 | // return: Entry Transform
215 | func layout(popover: PopoverView, config: PopoverConfig) -> CGAffineTransform {
216 | let container = config.container
217 | let sourceRect = config.sourceRect
218 | let containerRect = container.bounds.inset(by: config.containerInsets).inset(
219 | by: UIEdgeInsets(
220 | top: container.safeAreaInsets.top, left: container.safeAreaInsets.left,
221 | bottom: max(container.safeAreaInsets.bottom, KeyboardManager.shared.keyboardHeight), right: container.safeAreaInsets.right))
222 | var size = popover.sizeThatFits(containerRect.size.inset(by: config.insets)).inset(by: -config.insets)
223 | size.height = min(containerRect.height, size.height)
224 | size.width = min(containerRect.width, size.width)
225 |
226 | popover.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
227 | popover.layoutSubviews()
228 |
229 | let popoverOrigin = origin(
230 | positioning: config.positioning, sourceRect: sourceRect, size: size, containerRect: containerRect)
231 | let transformOrigin = origin(
232 | positioning: config.transformPositioning ?? config.positioning, sourceRect: config.sourceRect, size: .zero,
233 | containerRect: containerRect)
234 | let localTransformOrigin = transformOrigin - popoverOrigin
235 | popover.layer.anchorPoint = .zero
236 | popover.layer.position = popoverOrigin
237 | if config.positioning.verticalAlignment == .start || config.positioning.verticalAlignment == .after {
238 | popover.triangle.center = localTransformOrigin + CGPoint(x: 0, y: 8)
239 | popover.triangle.transform = CGAffineTransform.identity.scaledBy(x: 1, y: -1).rotated(by: CGFloat.pi / 4)
240 | } else {
241 | popover.triangle.center = localTransformOrigin + CGPoint(x: 0, y: -8)
242 | }
243 |
244 | switch config.transitionType {
245 | case .tooltip:
246 | return CGAffineTransform.identity.translatedBy(x: localTransformOrigin.x, y: localTransformOrigin.y)
247 | .scaledBy(x: 0.1, y: 0.1).translatedBy(x: -localTransformOrigin.x, y: -localTransformOrigin.y)
248 | case .notification:
249 | return CGAffineTransform.identity.translatedBy(x: 0, y: localTransformOrigin.y - popover.bounds.midY)
250 | }
251 | }
252 |
253 | public func relayout() {
254 | for popoverData in popovers {
255 | _ = layout(popover: popoverData.view, config: popoverData.config)
256 | }
257 | }
258 |
259 | @objc public func dismiss() {
260 | hide()
261 | }
262 |
263 | @objc public func dismissAll() {
264 | hideAll()
265 | }
266 |
267 | public func hide(completion: (() -> Void)? = nil) {
268 | guard let id = popovers.last?.view.identifier else { return }
269 | hide(id: id, completion: completion)
270 | }
271 |
272 | public func hide(id: String, completion: (() -> Void)? = nil) {
273 | if let popoverIndex = popovers.lastIndex(where: { $0.view.identifier == id }) {
274 | let popoverData = popovers.remove(at: popoverIndex)
275 | let entryTransform = popoverData.config.isSourceRectValid ? self.layout(popover: popoverData.view, config: popoverData.config) : popoverData.view.entryTransform
276 | if let gesture = popoverData.gesture {
277 | gesture.view?.removeGestureRecognizer(gesture)
278 | }
279 | UIView.animate(
280 | withDuration: popoverData.config.disappearingAnimationDuration, delay: 0, options: [.beginFromCurrentState],
281 | animations: {
282 | popoverData.view.transform = entryTransform
283 | popoverData.view.alpha = 0
284 | popoverData.backgroundView?.alpha = 0
285 | }
286 | ) { _ in
287 | completion?()
288 | popoverData.backgroundView?.removeFromSuperview()
289 | popoverData.view.removeFromSuperview()
290 | }
291 | popoverData.config.onDismiss?()
292 | } else {
293 | completion?()
294 | }
295 | }
296 |
297 | public func hideAll() {
298 | while !popovers.isEmpty {
299 | hide()
300 | }
301 | }
302 | }
303 |
--------------------------------------------------------------------------------