1022 lines
37 KiB
Swift
1022 lines
37 KiB
Swift
//
|
|
// FSPopoverView.swift
|
|
// FSPopoverView
|
|
//
|
|
// Created by Sheng on 2022/4/2.
|
|
// Copyright © 2023 Sheng. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
open class FSPopoverView: UIView {
|
|
|
|
// MARK: ArrowDirection
|
|
|
|
public enum ArrowDirection {
|
|
case up, down, left, right
|
|
}
|
|
|
|
// MARK: Properties/Open
|
|
|
|
/// The object that acts as the data source of the popover view.
|
|
///
|
|
/// * The data source must adopt the FSPopoverViewDataSource protocol.
|
|
/// * The data source is not retained.
|
|
/// * A reload request will be set when this property is set.
|
|
///
|
|
weak open var dataSource: FSPopoverViewDataSource? {
|
|
didSet {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
|
|
/// The transitioning delegate object is a custom object that you provide
|
|
/// and that conforms to the FSPopoverViewAnimatedTransitioning protocol.
|
|
///
|
|
/// * You can use this property to custom the transition animation of popover view.
|
|
/// * A default transitioning delegate is set for popover view.
|
|
/// * This object is not retained.
|
|
///
|
|
weak open var transitioningDelegate: FSPopoverViewAnimatedTransitioning? {
|
|
didSet {
|
|
if let scale = scaleTransition, scale !== transitioningDelegate {
|
|
scaleTransition = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The direction of the popover's arrow.
|
|
///
|
|
/// * You can change this property even though the popover view is displaying.
|
|
/// * A reload request will be set when this property is changed.
|
|
/// * See ``autosetsArrowDirection`` property for more information about this
|
|
/// property.
|
|
///
|
|
open var arrowDirection = FSPopoverView.ArrowDirection.up {
|
|
didSet {
|
|
if arrowDirection != oldValue, !autosetsArrowDirection, !isReloading {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether to set the arrow direction automatically.
|
|
/// Defaults to true.
|
|
///
|
|
/// * When this property is true, the popover view will determine the direction
|
|
/// of the arrow and update the ``arrowDirection`` property automatically.
|
|
/// * When this property is false, The popover view will calculate it's position
|
|
/// according to the ``arrowDirection`` property, and the ``arrowDirection`` property
|
|
/// will never be changed by inside.
|
|
/// * A reload request will be set when this property is changed.
|
|
///
|
|
/// - Important
|
|
/// * When this property is true, the popover view will calculate the appropriate
|
|
/// position based on the size of it's container and the position of the arrow.
|
|
/// So, when you set this property to false, you should ensure that there is enough
|
|
/// space in the container for the popover view to appear, otherwise the popover
|
|
/// view may be in the wrong place.
|
|
///
|
|
open var autosetsArrowDirection = true {
|
|
didSet {
|
|
if autosetsArrowDirection != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Properties/Public
|
|
|
|
/// The location of the arrow's vertex.
|
|
/// Defaults to (0, 0).
|
|
///
|
|
/// * This point is in the coordinate system of `containerView`.
|
|
/// * This point will be recalculated on reload operation.
|
|
/// * The value of ``showsArrow`` has no effect on this property.
|
|
///
|
|
final public private(set) var arrowPoint: CGPoint = .zero
|
|
|
|
/// The container view displaying the popover view.
|
|
/// This view will be created when the popover view needs to be displayed.
|
|
///
|
|
/// If popover view is displaying in a specified view, the specified view will be the superview
|
|
/// of the container view. Otherwise, a window will be created automatically inside the popover
|
|
/// view as the superview of the container view, and this window will be the same size as the
|
|
/// current screen.
|
|
///
|
|
/// The popover view is added to the container view.
|
|
/// The view hierarchy is:
|
|
/// ```
|
|
/// specified view / window
|
|
/// - container view (same size as specified view / window)
|
|
/// - dim background view (same size as container view)
|
|
/// - user interaction view (same size as container view)
|
|
/// - popover view
|
|
/// - popover container view
|
|
/// - background view
|
|
/// - content view (from data source)
|
|
/// ```
|
|
///
|
|
final weak public private(set) var containerView: UIView?
|
|
|
|
/// Arrow will be hidden when this property is set to false.
|
|
/// Default value see `FSPopoverViewAppearance`.
|
|
///
|
|
/// * A reload request will be set when this property is changed.
|
|
///
|
|
final public var showsArrow: Bool {
|
|
didSet {
|
|
if showsArrow != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether needs to show a dim background on container view.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
final public var showsDimBackground: Bool {
|
|
didSet {
|
|
dimBackgroundView.isHidden = !showsDimBackground
|
|
}
|
|
}
|
|
|
|
/// The corner radius of the popover view.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is set.
|
|
///
|
|
final public override var cornerRadius: CGFloat {
|
|
didSet {
|
|
if cornerRadius != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The size of the arrow.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is set.
|
|
///
|
|
final public var arrowSize: CGSize {
|
|
didSet {
|
|
if arrowSize != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The width of the popover view border.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is changed.
|
|
///
|
|
final public override var borderWidth: CGFloat {
|
|
didSet {
|
|
if borderWidth != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The color of the popover view border.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is set.
|
|
///
|
|
final public override var borderColor: UIColor? {
|
|
didSet {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
|
|
/// The color of the popover view shadow.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is set.
|
|
///
|
|
final public var shadowColor: UIColor? {
|
|
didSet {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
|
|
/// The radius of the popover view shadow.
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is changed.
|
|
///
|
|
final public var shadowRadius: CGFloat {
|
|
didSet {
|
|
if shadowRadius != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The opacity of the popover view shadow.
|
|
/// The value in this property must be in the range 0.0 (transparent) to 1.0 (opaque).
|
|
/// Default value see ``FSPopoverViewAppearance``.
|
|
///
|
|
/// * A reload request will be set when this property is changed.
|
|
///
|
|
final public var shadowOpacity: Float {
|
|
didSet {
|
|
if shadowOpacity != oldValue {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Properties/Override
|
|
|
|
/// It's objected to use this property to set the background color of popover view.
|
|
/// Use ``backgroundView`` of ``dataSource`` instead.
|
|
@available(*, unavailable)
|
|
final public override var backgroundColor: UIColor? {
|
|
get { return nil }
|
|
set {}
|
|
}
|
|
|
|
// MARK: Properties/Private
|
|
|
|
private var needsReload = false
|
|
|
|
private var isFreezing = false
|
|
|
|
private var isReloading = false
|
|
|
|
private let delegateRouter = FSPopoverViewDelegateRouter()
|
|
|
|
weak private var backgroundView: UIView?
|
|
|
|
weak private var contentView: UIView?
|
|
|
|
/// ``backgroundView`` and ``contentView`` will be added to this view.
|
|
///
|
|
/// * This view will be the same size as the popover view.
|
|
///
|
|
private lazy var popoverContainerView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .clear
|
|
return view
|
|
}()
|
|
|
|
weak private var borderLayer: CALayer?
|
|
weak private var shadowLayer: CALayer?
|
|
|
|
/// Size of `containerView`.
|
|
private var containerSize: CGSize = .zero
|
|
|
|
/// This rect is in the coordinate system of ``containerView``.
|
|
private var arrowReferRect: CGRect = .zero
|
|
|
|
/// If there is no specified view to display popover view,
|
|
/// this window will be created.
|
|
private var displayWindow: UIWindow?
|
|
|
|
/// The dim background on container view.
|
|
private lazy var dimBackgroundView: UIView = {
|
|
let view = UIView()
|
|
view.isHidden = true
|
|
view.backgroundColor = .init(white: 0.0, alpha: 0.25)
|
|
view.isUserInteractionEnabled = false
|
|
return view
|
|
}()
|
|
|
|
/// The view that receives user interaction.
|
|
private lazy var userInteractionView: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .clear
|
|
view.isUserInteractionEnabled = true
|
|
do {
|
|
let tap = UITapGestureRecognizer(target: self, action: #selector(p_handleTap))
|
|
tap.delegate = delegateRouter
|
|
view.addGestureRecognizer(tap)
|
|
}
|
|
return view
|
|
}()
|
|
|
|
/// The default value of transitioning delegate.
|
|
private var scaleTransition: FSPopoverViewTransitionScale?
|
|
|
|
// MARK: Initialization
|
|
|
|
public init() {
|
|
|
|
// appearance
|
|
let appearance = FSPopoverViewAppearance.shared
|
|
showsArrow = appearance.showsArrow
|
|
showsDimBackground = appearance.showsDimBackground
|
|
// cornerRadius = appearance.cornerRadius
|
|
arrowSize = appearance.arrowSize
|
|
// borderWidth = appearance.borderWidth
|
|
// borderColor = appearance.borderColor
|
|
shadowColor = appearance.shadowColor
|
|
shadowRadius = appearance.shadowRadius
|
|
shadowOpacity = appearance.shadowOpacity
|
|
|
|
super.init(frame: .zero)
|
|
cornerRadius = appearance.cornerRadius
|
|
borderWidth = appearance.borderWidth
|
|
borderColor = appearance.borderColor
|
|
p_didInitialize()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: Methods/Override
|
|
|
|
override open func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
reloadDataIfNeeded()
|
|
}
|
|
|
|
open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
if #unavailable(iOS 17) {
|
|
setNeedsReload()
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
open override class func appearance() -> Self {
|
|
return super.appearance()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
open override class func appearance(for trait: UITraitCollection) -> Self {
|
|
return super.appearance(for: trait)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
open override class func appearance(whenContainedInInstancesOf containerTypes: [UIAppearanceContainer.Type]) -> Self {
|
|
return super.appearance(whenContainedInInstancesOf: containerTypes)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
open override class func appearance(for trait: UITraitCollection, whenContainedInInstancesOf containerTypes: [UIAppearanceContainer.Type]) -> Self {
|
|
return super.appearance(for: trait, whenContainedInInstancesOf: containerTypes)
|
|
}
|
|
|
|
// MARK: Methods/Open
|
|
|
|
/// Tells the popover view to reload all of its contents.
|
|
///
|
|
/// - Requires
|
|
/// * Subclasses **must** call `super.setNeedsReload()` when overriding this method,
|
|
/// otherwise some bugs may occur.
|
|
///
|
|
/// - Note
|
|
/// * This method makes a note of the request and returns immediately. This method
|
|
/// does not force an immediate reload, all of the contents will reload in view's
|
|
/// next layout update cycle. This behavior allows you to consolidate all of your
|
|
/// content reloads to one layout update cycle, which is usually better for performance.
|
|
/// * You should call this method on the main thread.
|
|
///
|
|
open func setNeedsReload() {
|
|
p_mainThreadCheck()
|
|
needsReload = true
|
|
setNeedsLayout()
|
|
}
|
|
|
|
/// Reload the contents immediately if the reload operation is pending.
|
|
///
|
|
/// - Requires
|
|
/// * Subclasses **must** call `super.reloadDataIfNeeded()` when overriding this method,
|
|
/// otherwise some bugs may occur.
|
|
///
|
|
/// - Note
|
|
/// * Use this method to force the popover view to reload its contents immediately, but if
|
|
/// the reload operation is not pending, this method exits without modifying the contents
|
|
/// or calling any content-related callbacks.
|
|
/// * You should call this method on the main thread.
|
|
///
|
|
open func reloadDataIfNeeded() {
|
|
p_mainThreadCheck()
|
|
if needsReload {
|
|
reloadData()
|
|
}
|
|
}
|
|
|
|
/// Reload the contents immediately, even without any reload requests.
|
|
///
|
|
/// - Requires
|
|
/// * Subclasses **must** call `super.reloadData()` when overriding this method,
|
|
/// otherwise some bugs may occur.
|
|
///
|
|
/// - Note
|
|
/// * Calling this method will not have any animation, even if some contents are
|
|
/// changed, like size of content and direction of arrow and so on.
|
|
/// * You should call this method on the main thread.
|
|
///
|
|
open func reloadData() {
|
|
p_mainThreadCheck()
|
|
isReloading = true
|
|
p_reloadData()
|
|
isReloading = false
|
|
}
|
|
|
|
/// Get the transition context for the specified scene.
|
|
open func transitionContext(for scene: FSPopoverViewTransitionContext.Scene) -> FSPopoverViewTransitionContext {
|
|
return FSPopoverViewTransitionContext(scene: scene, popoverView: self, dimBackgroundView: dimBackgroundView)
|
|
}
|
|
|
|
/// Presents the popover and anchors it to the specified view.
|
|
open func present(fromView view: UIView,
|
|
displayIn specifiedView: UIView? = nil,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil) {
|
|
p_present(from: view.frame,
|
|
in: view.superview,
|
|
displayIn: specifiedView,
|
|
animated: animated,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Presents the popover and anchors it to the specified location.
|
|
/// - Parameters:
|
|
/// - view: The view containing the point.
|
|
open func present(fromPoint point: CGPoint,
|
|
in view: UIView? = nil,
|
|
displayIn specifiedView: UIView? = nil,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil) {
|
|
p_present(from: .init(origin: point, size: .zero),
|
|
in: view,
|
|
displayIn: specifiedView,
|
|
animated: animated,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Presents the popover and anchors it to the specified rect.
|
|
/// - Parameters:
|
|
/// - view: The view containing the rectangle.
|
|
open func present(fromRect rect: CGRect,
|
|
in view: UIView? = nil,
|
|
displayIn specifiedView: UIView? = nil,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil) {
|
|
p_present(from: rect,
|
|
in: view,
|
|
displayIn: specifiedView,
|
|
animated: animated,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Presents the popover and anchors it to the specified bar item.
|
|
open func present(fromBarItem barItem: UIBarItem, animated: Bool = true, completion: (() -> Void)? = nil) {
|
|
guard let view = barItem.value(forKey: "view") as? UIView else {
|
|
#if DEBUG
|
|
fatalError("The value of UIBarItem has been changed, this method can not be used anymore.")
|
|
#else
|
|
return
|
|
#endif
|
|
}
|
|
p_present(from: view.frame,
|
|
in: view.superview,
|
|
animated: animated,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Dismiss popover view.
|
|
open func dismiss(animated: Bool = true, isSelection: Bool = false, completion: (() -> Void)? = nil) {
|
|
p_dismiss(animated: animated, isSelection: isSelection, completion: completion)
|
|
}
|
|
}
|
|
|
|
// MARK: - Methods/Private
|
|
|
|
private extension FSPopoverView {
|
|
|
|
/// Invoked after initialization.
|
|
func p_didInitialize() {
|
|
|
|
popoverContainerView.backgroundColor = .clear
|
|
|
|
delegateRouter.gestureRecognizerShouldBeginHandler = { [unowned self] gestureRecognizer in
|
|
if let view = gestureRecognizer.view, view === self.userInteractionView {
|
|
if self.isFreezing {
|
|
return true
|
|
}
|
|
let location = gestureRecognizer.location(in: view)
|
|
return !self.frame.contains(location)
|
|
}
|
|
return false
|
|
}
|
|
|
|
if #available(iOS 17, *) {
|
|
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, previousTraitCollection: UITraitCollection) in
|
|
self.setNeedsReload()
|
|
}
|
|
}
|
|
|
|
addSubview(popoverContainerView)
|
|
popoverContainerView.inner.makeMarginConstraints(to: self)
|
|
|
|
scaleTransition = FSPopoverViewTransitionScale()
|
|
transitioningDelegate = scaleTransition
|
|
}
|
|
|
|
func p_mainThreadCheck() {
|
|
#if DEBUG
|
|
if !Thread.isMainThread {
|
|
fatalError("You must call this method on the main thread.")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func p_getArrowSize() -> CGSize {
|
|
guard showsArrow else {
|
|
return .zero
|
|
}
|
|
var size = arrowSize
|
|
size.width = max(size.width, 0)
|
|
size.height = max(size.height, 0)
|
|
return size
|
|
}
|
|
|
|
func p_createContainerView() -> UIView {
|
|
let view = UIView()
|
|
view.backgroundColor = .clear
|
|
return view
|
|
}
|
|
|
|
func p_createDisplayWindow() -> UIWindow {
|
|
let window = UIWindow()
|
|
window.windowLevel = .statusBar - 1
|
|
window.backgroundColor = .clear
|
|
if #available(iOS 13.0, *), let scene = UIApplication.shared.connectedScenes.filter({ $0.activationState == .foregroundActive }).first as? UIWindowScene {
|
|
window.windowScene = scene
|
|
}
|
|
return window
|
|
}
|
|
|
|
func p_prepareForDisplaying() {
|
|
// freezes the popover view before the popover view finishes displaying operation.
|
|
isFreezing = true
|
|
alpha = 1.0
|
|
transform = .identity
|
|
removeFromSuperview()
|
|
containerSize = .zero
|
|
containerView?.removeFromSuperview()
|
|
dimBackgroundView.alpha = 1.0
|
|
dimBackgroundView.transform = .identity
|
|
p_destroyDisplayWindow()
|
|
}
|
|
|
|
func p_setContainerView(_ view: UIView) {
|
|
|
|
containerView = view
|
|
|
|
dimBackgroundView.removeFromSuperview()
|
|
view.addSubview(dimBackgroundView)
|
|
dimBackgroundView.inner.makeMarginConstraints(to: view)
|
|
|
|
userInteractionView.removeFromSuperview()
|
|
view.addSubview(userInteractionView)
|
|
userInteractionView.inner.makeMarginConstraints(to: view)
|
|
}
|
|
|
|
func p_destroyDisplayWindow() {
|
|
displayWindow?.isHidden = true
|
|
displayWindow?.subviews.forEach { $0.removeFromSuperview() }
|
|
displayWindow = nil
|
|
}
|
|
|
|
/// Reset all contents to default values.
|
|
func p_resetContents() {
|
|
borderLayer?.removeFromSuperlayer()
|
|
shadowLayer?.removeFromSuperlayer()
|
|
contentView?.removeFromSuperview()
|
|
backgroundView?.removeFromSuperview()
|
|
borderLayer = nil
|
|
shadowLayer = nil
|
|
contentView = nil
|
|
backgroundView = nil
|
|
}
|
|
|
|
/// Reload all contents and recalculate the position and size of the popover view.
|
|
func p_reloadData() {
|
|
|
|
p_mainThreadCheck()
|
|
|
|
needsReload = false
|
|
|
|
// clear old contents
|
|
p_resetContents()
|
|
|
|
guard containerSize.width > 0, containerSize.height > 0 else {
|
|
return
|
|
}
|
|
|
|
// container safe area insets
|
|
let safeAreaInsets = dataSource?.containerSafeAreaInsets(for: self) ?? .zero
|
|
let safeContainerRect = CGRect(origin: .zero, size: containerSize).inset(by: safeAreaInsets)
|
|
|
|
// content size
|
|
let realContentSize = dataSource?.contentSize(for: self) ?? .zero
|
|
let contentSize: CGSize
|
|
|
|
// arrow size
|
|
let arrowSize = p_getArrowSize()
|
|
|
|
// arrow direction
|
|
do {
|
|
let horizontalContentSize: CGSize = {
|
|
var size = CGSize(width: cornerRadius * 2 + 10.0, height: arrowSize.height + cornerRadius * 2 + 10.0)
|
|
if realContentSize.width > size.width {
|
|
size.width = realContentSize.width
|
|
}
|
|
if realContentSize.height > size.height {
|
|
size.height = realContentSize.height
|
|
}
|
|
return size
|
|
}()
|
|
let verticalContentSize: CGSize = {
|
|
var size = CGSize(width: arrowSize.width + cornerRadius * 2 + 10.0, height: cornerRadius * 2 + 10.0)
|
|
if realContentSize.width > size.width {
|
|
size.width = realContentSize.width
|
|
}
|
|
if realContentSize.height > size.height {
|
|
size.height = realContentSize.height
|
|
}
|
|
return size
|
|
}()
|
|
if autosetsArrowDirection {
|
|
let referRect = arrowReferRect.insetBy(dx: -arrowSize.height, dy: -arrowSize.height)
|
|
let topSpace = CGSize(width: safeContainerRect.width, height: max(0.0, referRect.minY - safeContainerRect.minY))
|
|
let leftSpace = CGSize(width: max(0.0, referRect.minX - safeContainerRect.minX), height: safeContainerRect.height)
|
|
let bottomSpace = CGSize(width: safeContainerRect.width, height: max(0.0, safeContainerRect.maxY - referRect.maxY))
|
|
let rightSpace = CGSize(width: max(0.0, safeContainerRect.maxX - referRect.maxX), height: safeContainerRect.height)
|
|
// priority: up > down > left > right
|
|
if bottomSpace.width >= verticalContentSize.width && bottomSpace.height >= verticalContentSize.height {
|
|
contentSize = verticalContentSize
|
|
arrowDirection = .up
|
|
} else if topSpace.width >= verticalContentSize.width && topSpace.height >= verticalContentSize.height {
|
|
contentSize = verticalContentSize
|
|
arrowDirection = .down
|
|
} else if rightSpace.width >= horizontalContentSize.width && rightSpace.height >= horizontalContentSize.height {
|
|
contentSize = horizontalContentSize
|
|
arrowDirection = .left
|
|
} else if leftSpace.width >= horizontalContentSize.width && leftSpace.height >= horizontalContentSize.height {
|
|
contentSize = horizontalContentSize
|
|
arrowDirection = .right
|
|
} else {
|
|
// `up` will be set if there is not enough space to show popover view.
|
|
arrowDirection = .up
|
|
contentSize = verticalContentSize
|
|
#if DEBUG
|
|
let message = """
|
|
⚠️ Not enough space to show popover view, \
|
|
you may need to check if the popover view is showing on the wrong view. ⚠️
|
|
"""
|
|
print(message)
|
|
#endif
|
|
}
|
|
} else {
|
|
switch arrowDirection {
|
|
case .up, .down:
|
|
contentSize = verticalContentSize
|
|
case .left, .right:
|
|
contentSize = horizontalContentSize
|
|
}
|
|
}
|
|
}
|
|
|
|
// arrow point
|
|
// This point is in the coordinate system of container view.
|
|
do {
|
|
var point = CGPoint.zero
|
|
switch arrowDirection {
|
|
case .up:
|
|
point.x = arrowReferRect.midX
|
|
point.y = arrowReferRect.maxY
|
|
case .down:
|
|
point.x = arrowReferRect.midX
|
|
point.y = arrowReferRect.minY
|
|
case .left:
|
|
point.x = arrowReferRect.maxX
|
|
point.y = arrowReferRect.midY
|
|
case .right:
|
|
point.x = arrowReferRect.minX
|
|
point.y = arrowReferRect.midY
|
|
}
|
|
// If the point is on the edge, a little adjustment is required for improving the visual effect.
|
|
let arrowPointSafeRect: CGRect = {
|
|
var rect = safeContainerRect.insetBy(dx: cornerRadius, dy: cornerRadius)
|
|
rect = rect.insetBy(dx: 3.0, dy: 3.0)
|
|
rect = rect.insetBy(dx: arrowSize.width / 2, dy: arrowSize.width / 2)
|
|
return rect
|
|
}()
|
|
if !arrowPointSafeRect.contains(point) {
|
|
switch arrowDirection {
|
|
case .up, .down:
|
|
if point.x < arrowPointSafeRect.minX {
|
|
point.x = arrowPointSafeRect.minX
|
|
}
|
|
if point.x > arrowPointSafeRect.maxX {
|
|
point.x = arrowPointSafeRect.maxX
|
|
}
|
|
case .left, .right:
|
|
if point.y < arrowPointSafeRect.minY {
|
|
point.y = arrowPointSafeRect.minY
|
|
}
|
|
if point.y > arrowPointSafeRect.maxY {
|
|
point.y = arrowPointSafeRect.maxY
|
|
}
|
|
}
|
|
}
|
|
arrowPoint = point
|
|
}
|
|
|
|
// The frame of the popover view in the container view.
|
|
let frame: CGRect = {
|
|
let size: CGSize = {
|
|
var width: CGFloat = contentSize.width, height: CGFloat = contentSize.height
|
|
switch arrowDirection {
|
|
case .up, .down:
|
|
height += arrowSize.height
|
|
case .left, .right:
|
|
width += arrowSize.height
|
|
}
|
|
return .init(width: width, height: height)
|
|
}()
|
|
var origin = CGPoint.zero
|
|
switch arrowDirection {
|
|
case .up, .down:
|
|
origin.x = {
|
|
var x = arrowPoint.x - size.width / 2
|
|
if arrowPoint.x <= safeContainerRect.midX {
|
|
x = max(x, safeContainerRect.minX)
|
|
}
|
|
if arrowPoint.x > safeContainerRect.midX {
|
|
x = min(x, safeContainerRect.maxX - size.width)
|
|
}
|
|
return x
|
|
}()
|
|
case .left, .right:
|
|
origin.y = {
|
|
var y = arrowPoint.y - size.height / 2
|
|
if arrowPoint.y <= safeContainerRect.midY {
|
|
y = max(y, safeContainerRect.minY)
|
|
}
|
|
if arrowPoint.y > safeContainerRect.midY {
|
|
y = min(y, safeContainerRect.maxY - size.height)
|
|
}
|
|
return y
|
|
}()
|
|
}
|
|
switch arrowDirection {
|
|
case .up:
|
|
origin.y = arrowPoint.y
|
|
case .down:
|
|
origin.y = arrowPoint.y - size.height
|
|
case .left:
|
|
origin.x = arrowPoint.x
|
|
case .right:
|
|
origin.x = arrowPoint.x - size.width
|
|
}
|
|
return .init(origin: origin, size: size)
|
|
}()
|
|
self.frame = frame
|
|
|
|
// background view
|
|
if let view = dataSource?.backgroundView(for: self) {
|
|
popoverContainerView.addSubview(view)
|
|
backgroundView = view
|
|
view.inner.makeMarginConstraints(to: popoverContainerView)
|
|
}
|
|
|
|
// content view
|
|
if let view = dataSource?.contentView(for: self) {
|
|
popoverContainerView.addSubview(view)
|
|
contentView = view
|
|
var frame = CGRect.zero
|
|
frame.size = realContentSize
|
|
switch arrowDirection {
|
|
case .up:
|
|
frame.origin.x = (contentSize.width - realContentSize.width) / 2
|
|
frame.origin.y = arrowSize.height + (contentSize.height - realContentSize.height) / 2
|
|
case .left:
|
|
frame.origin.x = arrowSize.height + (contentSize.width - realContentSize.width) / 2
|
|
frame.origin.y = (contentSize.height - realContentSize.height) / 2
|
|
case .down, .right:
|
|
frame.origin.x = (contentSize.width - realContentSize.width) / 2
|
|
frame.origin.y = (contentSize.height - realContentSize.height) / 2
|
|
}
|
|
view.frame = frame
|
|
}
|
|
|
|
// draw popover view
|
|
do {
|
|
let arrowPointInPopover = CGPoint(x: arrowPoint.x - frame.minX, y: arrowPoint.y - frame.minY)
|
|
|
|
var context = FSPopoverDrawContext()
|
|
context.showsArrow = showsArrow
|
|
context.arrowSize = arrowSize
|
|
context.arrowPoint = arrowPointInPopover
|
|
context.arrowDirection = arrowDirection
|
|
context.cornerRadius = cornerRadius
|
|
context.contentSize = contentSize
|
|
context.popoverSize = frame.size
|
|
context.borderWidth = borderWidth
|
|
context.borderColor = borderColor
|
|
context.shadowColor = shadowColor
|
|
context.shadowRadius = shadowRadius
|
|
context.shadowOpacity = shadowOpacity
|
|
|
|
let drawer = FSPopoverDrawer(context: context)
|
|
|
|
// mask
|
|
do {
|
|
let path = drawer.generatePath()
|
|
let maskLayer = CAShapeLayer()
|
|
maskLayer.path = path.cgPath
|
|
popoverContainerView.layer.mask = maskLayer
|
|
}
|
|
|
|
// shadow
|
|
if let image = drawer.generateShadowImage() {
|
|
let layer = CALayer()
|
|
layer.contents = image.cgImage
|
|
self.layer.addSublayer(layer)
|
|
self.shadowLayer = layer
|
|
layer.frame.size = image.size
|
|
layer.frame.origin.x = (frame.width - image.size.width) / 2
|
|
layer.frame.origin.y = (frame.height - image.size.height) / 2
|
|
}
|
|
|
|
// border
|
|
if let image = drawer.generateBorderImage() {
|
|
let layer = CALayer()
|
|
layer.contents = image.cgImage
|
|
self.layer.addSublayer(layer)
|
|
self.borderLayer = layer
|
|
layer.frame.size = image.size
|
|
layer.frame.origin.x = (frame.width - image.size.width) / 2
|
|
layer.frame.origin.y = (frame.height - image.size.height) / 2
|
|
}
|
|
}
|
|
}
|
|
|
|
func p_present(from rect: CGRect,
|
|
in view: UIView? = nil,
|
|
displayIn specifiedView: UIView? = nil,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil) {
|
|
|
|
p_mainThreadCheck()
|
|
p_prepareForDisplaying()
|
|
|
|
let displayView: UIView = {
|
|
/// NOTE:
|
|
/// When displaying in the UIScrollView, container view can not get the correct size.
|
|
/// So ignore scroll view here.
|
|
if let view = specifiedView, !(view is UIScrollView), view.bounds.width > 0, view.bounds.height > 0.0 {
|
|
return view
|
|
}
|
|
let window = p_createDisplayWindow()
|
|
window.bounds = .init(origin: .zero, size: UIScreen.main.bounds.size)
|
|
window.isHidden = false
|
|
displayWindow = window
|
|
return window
|
|
}()
|
|
|
|
arrowReferRect = {
|
|
if let view = view {
|
|
return view.convert(rect, to: displayView)
|
|
}
|
|
return rect
|
|
}()
|
|
|
|
let containerView = p_createContainerView()
|
|
do {
|
|
p_setContainerView(containerView)
|
|
displayView.addSubview(containerView)
|
|
containerView.inner.makeMarginConstraints(to: displayView)
|
|
displayView.layoutIfNeeded()
|
|
}
|
|
|
|
containerSize = containerView.bounds.size
|
|
|
|
setNeedsReload()
|
|
|
|
// This operation will cause the method `layoutSubviews()` to be called, and then
|
|
// the method `reloadDataIfNeeded()` will be called, so it's unnecessary to call
|
|
// `reloadDataIfNeeded()` explicitly here.
|
|
containerView.addSubview(self)
|
|
// Layout the popover view for the animation of the next step.
|
|
containerView.layoutIfNeeded()
|
|
|
|
let completedHandler: (() -> Void) = { [unowned self] in
|
|
self.isFreezing = false
|
|
completion?()
|
|
}
|
|
|
|
if animated, let transitioning = transitioningDelegate {
|
|
let context = transitionContext(for: .present)
|
|
context.onDidCompleteTransition = completedHandler
|
|
transitioning.animateTransition(transitionContext: context)
|
|
} else {
|
|
completedHandler()
|
|
}
|
|
}
|
|
|
|
func p_dismiss(animated: Bool = true, isSelection: Bool = false, completion: (() -> Void)? = nil) {
|
|
|
|
p_mainThreadCheck()
|
|
|
|
// Can not do anything when the popover view begins disappearing.
|
|
isFreezing = true
|
|
|
|
containerView?.isUserInteractionEnabled = false
|
|
|
|
let completedHandler: (() -> Void) = { [unowned self] in
|
|
self.isFreezing = false
|
|
self.removeFromSuperview()
|
|
self.containerView?.removeFromSuperview()
|
|
self.p_destroyDisplayWindow()
|
|
completion?()
|
|
}
|
|
|
|
if animated, let transitioning = transitioningDelegate {
|
|
let context = transitionContext(for: .dismiss(isSelection))
|
|
context.onDidCompleteTransition = completedHandler
|
|
transitioning.animateTransition(transitionContext: context)
|
|
} else {
|
|
completedHandler()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Methods/Actions
|
|
|
|
private extension FSPopoverView {
|
|
|
|
@objc func p_handleTap() {
|
|
guard
|
|
!isFreezing, // Can not do anything while the popover view is freezing.
|
|
dataSource?.popoverViewShouldDismissOnTapOutside(self) ?? true
|
|
else {
|
|
return
|
|
}
|
|
p_dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Methods/Public
|
|
|
|
public extension FSPopoverView {
|
|
|
|
/// Call this method if you want to bring the popover view to the front of
|
|
/// the view displaying the popover view.
|
|
func moveToFront() {
|
|
guard
|
|
let containerView = containerView,
|
|
let superview = containerView.superview
|
|
else {
|
|
return
|
|
}
|
|
superview.bringSubviewToFront(containerView)
|
|
}
|
|
|
|
/// Call this method to get the maximum size in the direction.
|
|
///
|
|
/// Sometimes, the size of your content view may need to be adjusted to fit the popover view.
|
|
/// Such as your content view is a table view with a lot of cells, and the popover view doesn't
|
|
/// have enough space for the table view to show all of the cells, this requires the data source
|
|
/// object to return a proper content size. In this time, you need a size as a reference, and
|
|
/// this is where this method in handy.
|
|
///
|
|
/// - Important:
|
|
/// * Returns nil if the popover view is not yet ready for presenting, so it's recommended that
|
|
/// call this method in the content size method of data source object.
|
|
///
|
|
func maximumContentSizeOf(direction: FSPopoverView.ArrowDirection) -> CGSize? {
|
|
|
|
guard containerSize.width > 0, containerSize.height > 0 else {
|
|
return nil
|
|
}
|
|
|
|
let arrowSize = p_getArrowSize()
|
|
let referRect = arrowReferRect.insetBy(dx: -arrowSize.height, dy: -arrowSize.height)
|
|
let safeAreaInsets = dataSource?.containerSafeAreaInsets(for: self) ?? .zero
|
|
let safeContainerRect = CGRect(origin: .zero, size: containerSize).inset(by: safeAreaInsets)
|
|
|
|
switch direction {
|
|
case .up:
|
|
return CGSize(width: safeContainerRect.width, height: max(0.0, safeContainerRect.maxY - referRect.maxY))
|
|
case .down:
|
|
return CGSize(width: safeContainerRect.width, height: max(0.0, referRect.minY - safeContainerRect.minY))
|
|
case .left:
|
|
return CGSize(width: max(0.0, safeContainerRect.maxX - referRect.maxX), height: safeContainerRect.height)
|
|
case .right:
|
|
return CGSize(width: max(0.0, referRect.minX - safeContainerRect.minX), height: safeContainerRect.height)
|
|
}
|
|
}
|
|
}
|