├── 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 | --------------------------------------------------------------------------------