Visual_Novel_iOS/crush/Crush/Src/Components/UI/Popover/FSPopoverView.swift

1022 lines
37 KiB
Swift
Raw Normal View History

2025-10-09 10:29:35 +00:00
//
// 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)
}
}
}