c593c646ca
This problem arose after 6611ec8 commit. The root cause is linked to
this condition: `0 == layoutAdapter.offsetFromMostExpandedAnchor` at
line 589 in the method `Core.shouldScrollViewHandleTouch(_:point:velocity:)`.
If the value of `layoutAdapter.offsetFromMostExpandedAnchor` has a
floating point error, the condition evaluates to false. As a result,
the panel moves even when the tracking scroll view is intended to
scroll.
This problem may not occur if there is no floating point error.
862 lines
35 KiB
Swift
862 lines
35 KiB
Swift
// Copyright 2018-Present Shin Yamamoto. All rights reserved. MIT license.
|
|
|
|
import UIKit
|
|
import os.log
|
|
|
|
/// An interface for generating layout information for a panel.
|
|
@objc public protocol FloatingPanelLayout {
|
|
/// Returns the position of a panel in a `FloatingPanelController` view .
|
|
@objc var position: FloatingPanelPosition { get }
|
|
|
|
/// Returns the initial state when a panel is presented.
|
|
@objc var initialState: FloatingPanelState { get }
|
|
|
|
/// Returns the layout anchors to specify the snapping locations for each state.
|
|
@objc var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { get }
|
|
|
|
/// Returns layout constraints to determine the cross dimension of a panel.
|
|
@objc optional func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint]
|
|
|
|
/// Returns the alpha value of the backdrop of a panel for each state.
|
|
@objc optional func backdropAlpha(for state: FloatingPanelState) -> CGFloat
|
|
}
|
|
|
|
/// A layout object that lays out a panel in bottom sheet style.
|
|
@objcMembers
|
|
open class FloatingPanelBottomLayout: NSObject, FloatingPanelLayout {
|
|
public override init() {
|
|
super.init()
|
|
}
|
|
open var initialState: FloatingPanelState {
|
|
return .half
|
|
}
|
|
|
|
open var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] {
|
|
return [
|
|
.full: FloatingPanelLayoutAnchor(absoluteInset: 18.0, edge: .top, referenceGuide: .safeArea),
|
|
.half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea),
|
|
.tip: FloatingPanelLayoutAnchor(absoluteInset: 69.0, edge: .bottom, referenceGuide: .safeArea),
|
|
]
|
|
}
|
|
|
|
open var position: FloatingPanelPosition {
|
|
return .bottom
|
|
}
|
|
|
|
open func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
|
|
return [
|
|
surfaceView.leftAnchor.constraint(equalTo: view.fp_safeAreaLayoutGuide.leftAnchor, constant: 0.0),
|
|
surfaceView.rightAnchor.constraint(equalTo: view.fp_safeAreaLayoutGuide.rightAnchor, constant: 0.0),
|
|
]
|
|
}
|
|
|
|
open func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
|
|
return state == .full ? 0.3 : 0.0
|
|
}
|
|
}
|
|
|
|
struct LayoutSegment {
|
|
let lower: FloatingPanelState?
|
|
let upper: FloatingPanelState?
|
|
}
|
|
|
|
class LayoutAdapter {
|
|
private unowned var vc: FloatingPanelController
|
|
private let defaultLayout = FloatingPanelBottomLayout()
|
|
|
|
fileprivate var layout: FloatingPanelLayout {
|
|
didSet {
|
|
surfaceView.position = position
|
|
}
|
|
}
|
|
|
|
private var surfaceView: SurfaceView {
|
|
return vc.surfaceView
|
|
}
|
|
private var backdropView: BackdropView {
|
|
return vc.backdropView
|
|
}
|
|
private var safeAreaInsets: UIEdgeInsets {
|
|
return vc.fp_safeAreaInsets
|
|
}
|
|
|
|
private var initialConst: CGFloat = 0.0
|
|
|
|
private var fixedConstraints: [NSLayoutConstraint] = []
|
|
|
|
private var stateConstraints: [FloatingPanelState: [NSLayoutConstraint]] = [:]
|
|
private var offConstraints: [NSLayoutConstraint] = []
|
|
private var fitToBoundsConstraint: NSLayoutConstraint?
|
|
|
|
private(set) var interactionConstraint: NSLayoutConstraint?
|
|
private(set) var attractionConstraint: NSLayoutConstraint?
|
|
|
|
private var staticConstraint: NSLayoutConstraint?
|
|
|
|
/// A layout constraint to limit the content size in ``FloatingPanelAdaptiveLayoutAnchor``.
|
|
private var contentBoundingConstraint: NSLayoutConstraint?
|
|
|
|
private var anchorStates: Set<FloatingPanelState> {
|
|
return Set(layout.anchors.keys)
|
|
}
|
|
|
|
private var sortedAnchorStates: [FloatingPanelState] {
|
|
return anchorStates.sorted(by: {
|
|
return $0.order < $1.order
|
|
})
|
|
}
|
|
|
|
var initialState: FloatingPanelState {
|
|
layout.initialState
|
|
}
|
|
|
|
var position: FloatingPanelPosition {
|
|
layout.position
|
|
}
|
|
|
|
var validStates: Set<FloatingPanelState> {
|
|
return anchorStates.union([.hidden])
|
|
}
|
|
|
|
var sortedAnchorStatesByCoordinate: [FloatingPanelState] {
|
|
return anchorStates.sorted(by: {
|
|
switch position {
|
|
case .top, .left:
|
|
return $0.order < $1.order
|
|
case .bottom, .right:
|
|
return $0.order > $1.order
|
|
}
|
|
})
|
|
}
|
|
|
|
private var leastCoordinateState: FloatingPanelState {
|
|
return sortedAnchorStatesByCoordinate.first ?? .hidden
|
|
}
|
|
|
|
private var mostCoordinateState: FloatingPanelState {
|
|
return sortedAnchorStatesByCoordinate.last ?? .hidden
|
|
}
|
|
|
|
var leastExpandedState: FloatingPanelState {
|
|
if sortedAnchorStates.count == 1 {
|
|
return .hidden
|
|
}
|
|
return sortedAnchorStates.first ?? .hidden
|
|
}
|
|
|
|
var mostExpandedState: FloatingPanelState {
|
|
if sortedAnchorStates.count == 1 {
|
|
return sortedAnchorStates[0]
|
|
}
|
|
return sortedAnchorStates.last ?? .hidden
|
|
}
|
|
|
|
var adjustedContentInsets: UIEdgeInsets {
|
|
switch position {
|
|
case .top:
|
|
return UIEdgeInsets(top: safeAreaInsets.top,
|
|
left: 0.0,
|
|
bottom: 0.0,
|
|
right: 0.0)
|
|
case .left:
|
|
return UIEdgeInsets(top: 0.0,
|
|
left: safeAreaInsets.left,
|
|
bottom: 0.0,
|
|
right: 0.0)
|
|
case .bottom:
|
|
return UIEdgeInsets(top: 0.0,
|
|
left: 0.0,
|
|
bottom: safeAreaInsets.bottom,
|
|
right: 0.0)
|
|
case .right:
|
|
return UIEdgeInsets(top: 0.0,
|
|
left: 0.0,
|
|
bottom: 0.0,
|
|
right: safeAreaInsets.right)
|
|
}
|
|
}
|
|
|
|
/*
|
|
Returns a constraint based value in the interaction and animation.
|
|
|
|
So that it doesn't need to call `surfaceView.layoutIfNeeded()`
|
|
after every interaction and animation update. It has an effect on
|
|
the smooth interaction because the content view doesn't need to update
|
|
its layout frequently.
|
|
*/
|
|
var surfaceLocation: CGPoint {
|
|
get {
|
|
var pos: CGFloat
|
|
if let constraint = interactionConstraint {
|
|
pos = constraint.constant
|
|
} else if let animationConstraint = attractionConstraint, let anchor = layout.anchors[vc.state] {
|
|
switch position {
|
|
case .top, .bottom:
|
|
switch referenceEdge(of: anchor) {
|
|
case .top:
|
|
pos = animationConstraint.constant
|
|
if anchor.referenceGuide == .safeArea {
|
|
pos += safeAreaInsets.top
|
|
}
|
|
case .bottom:
|
|
pos = vc.view.bounds.height + animationConstraint.constant
|
|
if anchor.referenceGuide == .safeArea {
|
|
pos -= safeAreaInsets.bottom
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
case .left, .right:
|
|
switch referenceEdge(of: anchor) {
|
|
case .left:
|
|
pos = animationConstraint.constant
|
|
if anchor.referenceGuide == .safeArea {
|
|
pos += safeAreaInsets.left
|
|
}
|
|
case .right:
|
|
pos = vc.view.bounds.width + animationConstraint.constant
|
|
if anchor.referenceGuide == .safeArea {
|
|
pos -= safeAreaInsets.right
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
}
|
|
} else {
|
|
pos = edgePosition(surfaceView.frame).rounded(by: surfaceView.fp_displayScale)
|
|
}
|
|
switch position {
|
|
case .top, .bottom:
|
|
return CGPoint(x: 0.0, y: pos)
|
|
case .left, .right:
|
|
return CGPoint(x: pos, y: 0.0)
|
|
}
|
|
}
|
|
set {
|
|
let pos = position.mainLocation(newValue)
|
|
if let constraint = interactionConstraint {
|
|
constraint.constant = pos
|
|
} else if let animationConstraint = attractionConstraint, let anchor = layout.anchors[vc.state] {
|
|
let refEdge = referenceEdge(of: anchor)
|
|
switch refEdge {
|
|
case .top, .left:
|
|
animationConstraint.constant = pos
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant -= refEdge.inset(of: safeAreaInsets)
|
|
}
|
|
case .bottom, .right:
|
|
animationConstraint.constant = pos - position.mainDimension(vc.view.bounds.size)
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant += refEdge.inset(of: safeAreaInsets)
|
|
}
|
|
}
|
|
} else {
|
|
switch position {
|
|
case .top:
|
|
return surfaceView.frame.origin.y = pos - surfaceView.bounds.height
|
|
case .left:
|
|
return surfaceView.frame.origin.x = pos - surfaceView.bounds.width
|
|
case .bottom:
|
|
return surfaceView.frame.origin.y = pos
|
|
case .right:
|
|
return surfaceView.frame.origin.x = pos
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var offsetFromMostExpandedAnchor: CGFloat {
|
|
let offset: CGFloat
|
|
switch position {
|
|
case .top, .left:
|
|
offset = edgePosition(surfaceView.presentationFrame) - position(for: mostExpandedState)
|
|
case .bottom, .right:
|
|
offset = position(for: mostExpandedState) - edgePosition(surfaceView.presentationFrame)
|
|
}
|
|
return offset.rounded(by: surfaceView.fp_displayScale)
|
|
}
|
|
|
|
private var hiddenAnchor: FloatingPanelLayoutAnchoring {
|
|
switch position {
|
|
case .top:
|
|
return FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .top, referenceGuide: .superview)
|
|
case .left:
|
|
return FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .left, referenceGuide: .superview)
|
|
case .bottom:
|
|
return FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .bottom, referenceGuide: .superview)
|
|
case .right:
|
|
return FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .right, referenceGuide: .superview)
|
|
}
|
|
}
|
|
|
|
init(vc: FloatingPanelController, layout: FloatingPanelLayout) {
|
|
self.vc = vc
|
|
self.layout = layout
|
|
}
|
|
|
|
func surfaceLocation(for state: FloatingPanelState) -> CGPoint {
|
|
let pos = position(for: state).rounded(by: surfaceView.fp_displayScale)
|
|
switch layout.position {
|
|
case .top, .bottom:
|
|
return CGPoint(x: 0.0, y: pos)
|
|
case .left, .right:
|
|
return CGPoint(x: pos, y: 0.0)
|
|
}
|
|
}
|
|
|
|
func position(for state: FloatingPanelState) -> CGFloat {
|
|
let bounds = vc.view.bounds
|
|
let anchor = layout.anchors[state] ?? self.hiddenAnchor
|
|
|
|
switch anchor {
|
|
case let anchor as FloatingPanelIntrinsicLayoutAnchor:
|
|
let intrinsicLength = position.mainDimension(surfaceView.intrinsicContentSize)
|
|
let diff = anchor.isAbsolute ? anchor.offset : intrinsicLength * anchor.offset
|
|
|
|
switch position {
|
|
case .top, .left:
|
|
var base: CGFloat = 0.0
|
|
if anchor.referenceGuide == .safeArea {
|
|
base += position.inset(safeAreaInsets)
|
|
}
|
|
return base + intrinsicLength - diff
|
|
case .bottom, .right:
|
|
var base = position.mainDimension(bounds.size)
|
|
if anchor.referenceGuide == .safeArea {
|
|
base -= position.inset(safeAreaInsets)
|
|
}
|
|
return base - intrinsicLength + diff
|
|
}
|
|
case let anchor as FloatingPanelAdaptiveLayoutAnchor:
|
|
let dimension = layout.position.mainDimension(anchor.contentLayoutGuide.layoutFrame.size)
|
|
let diff = anchor.distance(from: dimension)
|
|
var referenceBoundsLength = layout.position.mainDimension(bounds.size)
|
|
switch layout.position {
|
|
case .top, .left:
|
|
if anchor.referenceGuide == .safeArea {
|
|
referenceBoundsLength += position.inset(safeAreaInsets)
|
|
}
|
|
let maxPosition: CGFloat = {
|
|
if let maxBounds = anchor.contentBoundingGuide.maxBounds(vc) {
|
|
return layout.position.mainLocation(maxBounds.origin)
|
|
+ layout.position.mainDimension(maxBounds.size)
|
|
} else {
|
|
return .infinity
|
|
}
|
|
}()
|
|
return min(dimension - diff, maxPosition)
|
|
case .bottom, .right:
|
|
if anchor.referenceGuide == .safeArea {
|
|
referenceBoundsLength -= position.inset(safeAreaInsets)
|
|
}
|
|
let minPosition: CGFloat = {
|
|
if let maxBounds = anchor.contentBoundingGuide.maxBounds(vc) {
|
|
return layout.position.mainLocation(maxBounds.origin)
|
|
} else {
|
|
return -(.infinity)
|
|
}
|
|
}()
|
|
return max(referenceBoundsLength - dimension + diff, minPosition)
|
|
}
|
|
case let anchor as FloatingPanelLayoutAnchor:
|
|
let referenceBounds = anchor.referenceGuide == .safeArea ? bounds.inset(by: safeAreaInsets) : bounds
|
|
let diff = anchor.isAbsolute ? anchor.inset : position.mainDimension(referenceBounds.size) * anchor.inset
|
|
switch anchor.referenceEdge {
|
|
case .top:
|
|
return referenceBounds.minY + diff
|
|
case .left:
|
|
return referenceBounds.minX + diff
|
|
case .bottom:
|
|
return referenceBounds.maxY - diff
|
|
case .right:
|
|
return referenceBounds.maxX - diff
|
|
}
|
|
default:
|
|
fatalError("Unsupported a FloatingPanelLayoutAnchoring object")
|
|
}
|
|
}
|
|
|
|
func isIntrinsicAnchor(state: FloatingPanelState) -> Bool {
|
|
return layout.anchors[state] is FloatingPanelIntrinsicLayoutAnchor
|
|
}
|
|
|
|
private func edgePosition(_ frame: CGRect) -> CGFloat {
|
|
switch position {
|
|
case .top:
|
|
return frame.maxY
|
|
case .left:
|
|
return frame.maxX
|
|
case .bottom:
|
|
return frame.minY
|
|
case .right:
|
|
return frame.minX
|
|
}
|
|
}
|
|
|
|
private func referenceEdge(of anchor: FloatingPanelLayoutAnchoring) -> FloatingPanelReferenceEdge {
|
|
switch anchor {
|
|
case is FloatingPanelIntrinsicLayoutAnchor,
|
|
is FloatingPanelAdaptiveLayoutAnchor:
|
|
switch position {
|
|
case .top: return .top
|
|
case .left: return .left
|
|
case .bottom: return .bottom
|
|
case .right: return .right
|
|
}
|
|
case let anchor as FloatingPanelLayoutAnchor:
|
|
return anchor.referenceEdge
|
|
default:
|
|
fatalError("Unsupported a FloatingPanelLayoutAnchoring object")
|
|
}
|
|
}
|
|
|
|
func prepareLayout() {
|
|
NSLayoutConstraint.deactivate(fixedConstraints)
|
|
|
|
surfaceView.translatesAutoresizingMaskIntoConstraints = false
|
|
backdropView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
// Fixed constraints of surface and backdrop views
|
|
let surfaceConstraints: [NSLayoutConstraint]
|
|
if let constraints = layout.prepareLayout?(surfaceView: surfaceView, in: vc.view) {
|
|
surfaceConstraints = constraints
|
|
} else {
|
|
switch position {
|
|
case .top, .bottom:
|
|
surfaceConstraints = [
|
|
surfaceView.leftAnchor.constraint(equalTo: vc.fp_safeAreaLayoutGuide.leftAnchor, constant: 0.0),
|
|
surfaceView.rightAnchor.constraint(equalTo: vc.fp_safeAreaLayoutGuide.rightAnchor, constant: 0.0),
|
|
]
|
|
case .left, .right:
|
|
surfaceConstraints = [
|
|
surfaceView.topAnchor.constraint(equalTo: vc.fp_safeAreaLayoutGuide.topAnchor, constant: 0.0),
|
|
surfaceView.bottomAnchor.constraint(equalTo: vc.fp_safeAreaLayoutGuide.bottomAnchor, constant: 0.0),
|
|
]
|
|
}
|
|
}
|
|
let backdropConstraints = [
|
|
backdropView.topAnchor.constraint(equalTo: vc.view.topAnchor, constant: 0.0),
|
|
backdropView.leftAnchor.constraint(equalTo: vc.view.leftAnchor,constant: 0.0),
|
|
backdropView.rightAnchor.constraint(equalTo: vc.view.rightAnchor, constant: 0.0),
|
|
backdropView.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor, constant: 0.0),
|
|
]
|
|
|
|
fixedConstraints = surfaceConstraints + backdropConstraints
|
|
|
|
NSLayoutConstraint.deactivate(constraint: self.fitToBoundsConstraint)
|
|
self.fitToBoundsConstraint = nil
|
|
|
|
if vc.contentMode == .fitToBounds {
|
|
switch position {
|
|
case .top:
|
|
fitToBoundsConstraint = surfaceView.topAnchor.constraint(equalTo: vc.view.topAnchor, constant: 0.0)
|
|
fitToBoundsConstraint?.identifier = "FloatingPanel-fit-to-top"
|
|
case .left:
|
|
fitToBoundsConstraint = surfaceView.leftAnchor.constraint(equalTo: vc.view.leftAnchor, constant: 0.0)
|
|
fitToBoundsConstraint?.identifier = "FloatingPanel-fit-to-left"
|
|
case .bottom:
|
|
fitToBoundsConstraint = surfaceView.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor, constant: 0.0)
|
|
fitToBoundsConstraint?.identifier = "FloatingPanel-fit-to-bottom"
|
|
case .right:
|
|
fitToBoundsConstraint = surfaceView.rightAnchor.constraint(equalTo: vc.view.rightAnchor, constant: 0.0)
|
|
fitToBoundsConstraint?.identifier = "FloatingPanel-fit-to-right"
|
|
}
|
|
fitToBoundsConstraint?.priority = .defaultHigh
|
|
}
|
|
|
|
updateStateConstraints()
|
|
}
|
|
|
|
private func updateStateConstraints() {
|
|
let allStateConstraints = stateConstraints.flatMap { $1 }
|
|
NSLayoutConstraint.deactivate(allStateConstraints + offConstraints)
|
|
stateConstraints.removeAll()
|
|
for state in layout.anchors.keys {
|
|
stateConstraints[state] = layout.anchors[state]?
|
|
.layoutConstraints(vc, for: position)
|
|
.map{ $0.identifier = "FloatingPanel-\(state)-constraint"; return $0 }
|
|
}
|
|
let hiddenAnchor = layout.anchors[.hidden] ?? self.hiddenAnchor
|
|
offConstraints = hiddenAnchor.layoutConstraints(vc, for: position)
|
|
offConstraints.forEach {
|
|
$0.identifier = "FloatingPanel-hidden-constraint"
|
|
}
|
|
}
|
|
|
|
func startInteraction(at state: FloatingPanelState, offset: CGPoint = .zero) {
|
|
if let constraint = interactionConstraint {
|
|
initialConst = constraint.constant
|
|
return
|
|
}
|
|
|
|
tearDownAttraction()
|
|
|
|
NSLayoutConstraint.deactivate(stateConstraints.flatMap { $1 } + offConstraints)
|
|
|
|
initialConst = edgePosition(surfaceView.frame) + offset.y
|
|
|
|
let constraint: NSLayoutConstraint
|
|
switch position {
|
|
case .top:
|
|
constraint = surfaceView.bottomAnchor.constraint(equalTo: vc.view.topAnchor, constant: initialConst)
|
|
case .left:
|
|
constraint = surfaceView.rightAnchor.constraint(equalTo: vc.view.leftAnchor, constant: initialConst)
|
|
case .bottom:
|
|
constraint = surfaceView.topAnchor.constraint(equalTo: vc.view.topAnchor, constant: initialConst)
|
|
case .right:
|
|
constraint = surfaceView.leftAnchor.constraint(equalTo: vc.view.leftAnchor, constant: initialConst)
|
|
}
|
|
|
|
constraint.priority = .required
|
|
constraint.identifier = "FloatingPanel-interaction"
|
|
|
|
NSLayoutConstraint.activate([constraint])
|
|
self.interactionConstraint = constraint
|
|
}
|
|
|
|
func endInteraction(at state: FloatingPanelState) {
|
|
// Don't deactivate `interactiveTopConstraint` here because it leads to
|
|
// unsatisfiable constraints
|
|
|
|
if self.interactionConstraint == nil {
|
|
// Activate `interactiveTopConstraint` for `fitToBounds` mode.
|
|
// It goes through this path when the pan gesture state jumps
|
|
// from .begin to .end.
|
|
startInteraction(at: state)
|
|
}
|
|
}
|
|
|
|
func setUpAttraction(to state: FloatingPanelState) -> (NSLayoutConstraint, CGFloat) {
|
|
NSLayoutConstraint.deactivate(constraint: attractionConstraint)
|
|
|
|
let anchor = layout.anchors[state] ?? self.hiddenAnchor
|
|
|
|
NSLayoutConstraint.deactivate(stateConstraints.flatMap { $1 } + offConstraints)
|
|
NSLayoutConstraint.deactivate(constraint: interactionConstraint)
|
|
interactionConstraint = nil
|
|
|
|
let layoutGuideProvider: LayoutGuideProvider
|
|
switch anchor.referenceGuide {
|
|
case .safeArea:
|
|
layoutGuideProvider = vc.fp_safeAreaLayoutGuide
|
|
case .superview:
|
|
layoutGuideProvider = vc.view
|
|
}
|
|
let currentY = position.mainLocation(surfaceLocation)
|
|
let baseHeight = position.mainDimension(vc.view.bounds.size)
|
|
|
|
let animationConstraint: NSLayoutConstraint
|
|
var targetY = position(for: state)
|
|
|
|
switch position {
|
|
case .top:
|
|
switch referenceEdge(of: anchor) {
|
|
case .top:
|
|
animationConstraint = surfaceView.bottomAnchor.constraint(equalTo: layoutGuideProvider.topAnchor,
|
|
constant: currentY)
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant -= safeAreaInsets.top
|
|
targetY -= safeAreaInsets.top
|
|
}
|
|
case .bottom:
|
|
let baseHeight = vc.view.bounds.height
|
|
targetY = -(baseHeight - targetY)
|
|
animationConstraint = surfaceView.bottomAnchor.constraint(equalTo: layoutGuideProvider.bottomAnchor,
|
|
constant: -(baseHeight - currentY))
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant += safeAreaInsets.bottom
|
|
targetY += safeAreaInsets.bottom
|
|
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
case .left:
|
|
switch referenceEdge(of: anchor) {
|
|
case .left:
|
|
animationConstraint = surfaceView.rightAnchor.constraint(equalTo: layoutGuideProvider.leftAnchor,
|
|
constant: currentY)
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant -= safeAreaInsets.right
|
|
targetY -= safeAreaInsets.right
|
|
}
|
|
case .right:
|
|
targetY = -(baseHeight - targetY)
|
|
animationConstraint = surfaceView.rightAnchor.constraint(equalTo: layoutGuideProvider.rightAnchor,
|
|
constant: -(baseHeight - currentY))
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant += safeAreaInsets.left
|
|
targetY += safeAreaInsets.left
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
case .bottom:
|
|
switch referenceEdge(of: anchor) {
|
|
case .top:
|
|
animationConstraint = surfaceView.topAnchor.constraint(equalTo: layoutGuideProvider.topAnchor,
|
|
constant: currentY)
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant -= safeAreaInsets.top
|
|
targetY -= safeAreaInsets.top
|
|
}
|
|
case .bottom:
|
|
targetY = -(baseHeight - targetY)
|
|
animationConstraint = surfaceView.topAnchor.constraint(equalTo: layoutGuideProvider.bottomAnchor,
|
|
constant: -(baseHeight - currentY))
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant += safeAreaInsets.bottom
|
|
targetY += safeAreaInsets.bottom
|
|
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
case .right:
|
|
switch referenceEdge(of: anchor) {
|
|
case .left:
|
|
animationConstraint = surfaceView.leftAnchor.constraint(equalTo: layoutGuideProvider.leftAnchor,
|
|
constant: currentY)
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant -= safeAreaInsets.left
|
|
targetY -= safeAreaInsets.left
|
|
}
|
|
case .right:
|
|
targetY = -(baseHeight - targetY)
|
|
animationConstraint = surfaceView.leftAnchor.constraint(equalTo: layoutGuideProvider.rightAnchor,
|
|
constant: -(baseHeight - currentY))
|
|
if anchor.referenceGuide == .safeArea {
|
|
animationConstraint.constant += safeAreaInsets.right
|
|
targetY += safeAreaInsets.right
|
|
}
|
|
default:
|
|
fatalError("Unsupported reference edges")
|
|
}
|
|
}
|
|
|
|
animationConstraint.priority = .defaultHigh
|
|
animationConstraint.identifier = "FloatingPanel-attraction"
|
|
|
|
NSLayoutConstraint.activate([animationConstraint])
|
|
self.attractionConstraint = animationConstraint
|
|
return (animationConstraint, targetY)
|
|
}
|
|
|
|
private func tearDownAttraction() {
|
|
NSLayoutConstraint.deactivate(constraint: attractionConstraint)
|
|
attractionConstraint = nil
|
|
}
|
|
|
|
// The method is separated from prepareLayout(to:) for the rotation support
|
|
// It must be called in FloatingPanelController.traitCollectionDidChange(_:)
|
|
func updateStaticConstraint() {
|
|
NSLayoutConstraint.deactivate([staticConstraint, contentBoundingConstraint].compactMap{ $0 })
|
|
staticConstraint = nil
|
|
contentBoundingConstraint = nil
|
|
|
|
if vc.contentMode == .fitToBounds {
|
|
surfaceView.containerOverflow = 0
|
|
return
|
|
}
|
|
|
|
let anchor = layout.anchors[self.mostExpandedState]!
|
|
let surfaceAnchor = position.mainDimensionAnchor(surfaceView)
|
|
switch anchor {
|
|
case let anchor as FloatingPanelIntrinsicLayoutAnchor:
|
|
var constant = position.mainDimension(surfaceView.intrinsicContentSize)
|
|
if anchor.referenceGuide == .safeArea {
|
|
constant += position.inset(safeAreaInsets)
|
|
}
|
|
staticConstraint = surfaceAnchor.constraint(equalToConstant: constant)
|
|
case let anchor as FloatingPanelAdaptiveLayoutAnchor:
|
|
let constant: CGFloat
|
|
if anchor.referenceGuide == .safeArea {
|
|
constant = position.inset(safeAreaInsets)
|
|
} else {
|
|
constant = 0.0
|
|
}
|
|
let baseAnchor = position.mainDimensionAnchor(anchor.contentLayoutGuide)
|
|
if let boundingLayoutGuide = anchor.contentBoundingGuide.layoutGuide(vc) {
|
|
if anchor.isAbsolute {
|
|
contentBoundingConstraint = baseAnchor.constraint(lessThanOrEqualTo: position.mainDimensionAnchor(boundingLayoutGuide),
|
|
constant: anchor.offset)
|
|
} else {
|
|
contentBoundingConstraint = baseAnchor.constraint(lessThanOrEqualTo: position.mainDimensionAnchor(boundingLayoutGuide),
|
|
multiplier: anchor.offset)
|
|
}
|
|
staticConstraint = surfaceAnchor.constraint(lessThanOrEqualTo: baseAnchor, constant: constant)
|
|
} else {
|
|
staticConstraint = surfaceAnchor.constraint(equalTo: baseAnchor, constant: constant)
|
|
}
|
|
default:
|
|
switch position {
|
|
case .top, .left:
|
|
staticConstraint = surfaceAnchor.constraint(equalToConstant: position(for: self.mostCoordinateState))
|
|
case .bottom, .right:
|
|
let rootViewAnchor = position.mainDimensionAnchor(vc.view)
|
|
staticConstraint = rootViewAnchor.constraint(equalTo: surfaceAnchor,
|
|
constant: position(for: self.leastCoordinateState))
|
|
}
|
|
}
|
|
|
|
switch position {
|
|
case .top, .bottom:
|
|
staticConstraint?.identifier = "FloatingPanel-static-height"
|
|
case .left, .right:
|
|
staticConstraint?.identifier = "FloatingPanel-static-width"
|
|
}
|
|
|
|
NSLayoutConstraint.activate([staticConstraint, contentBoundingConstraint].compactMap{ $0 })
|
|
|
|
surfaceView.containerOverflow = position.mainDimension(vc.view.bounds.size)
|
|
}
|
|
|
|
func updateInteractiveEdgeConstraint(diff: CGFloat, scrollingContent: Bool, allowsRubberBanding: (UIRectEdge) -> Bool) {
|
|
defer {
|
|
os_log(msg, log: devLog, type: .debug, "update surface location = \(surfaceLocation)")
|
|
}
|
|
|
|
let minConst: CGFloat = position(for: leastCoordinateState)
|
|
let maxConst: CGFloat = position(for: mostCoordinateState)
|
|
|
|
var const = initialConst + diff
|
|
|
|
let base = position.mainDimension(vc.view.bounds.size)
|
|
// Rubber-banding top buffer
|
|
if allowsRubberBanding(.top), const < minConst {
|
|
let buffer = minConst - const
|
|
const = minConst - rubberBandEffect(for: buffer, base: base)
|
|
}
|
|
|
|
// Rubber-banding bottom buffer
|
|
if allowsRubberBanding(.bottom), const > maxConst {
|
|
let buffer = const - maxConst
|
|
const = maxConst + rubberBandEffect(for: buffer, base: base)
|
|
}
|
|
|
|
if scrollingContent {
|
|
const = min(max(const, minConst), maxConst)
|
|
}
|
|
|
|
interactionConstraint?.constant = const
|
|
}
|
|
|
|
// According to @chpwn's tweet: https://twitter.com/chpwn/status/285540192096497664
|
|
// x = distance from the edge
|
|
// c = constant value, UIScrollView uses 0.55
|
|
// d = dimension, either width or height
|
|
private func rubberBandEffect(for buffer: CGFloat, base: CGFloat) -> CGFloat {
|
|
return (1.0 - (1.0 / ((buffer * 0.55 / base) + 1.0))) * base
|
|
}
|
|
|
|
func activateLayout(for state: FloatingPanelState, forceLayout: Bool = false) {
|
|
defer {
|
|
if forceLayout {
|
|
layoutSurfaceIfNeeded()
|
|
os_log(msg, log: devLog, type: .debug, "activateLayout for \(state) -- surface.presentation = \(self.surfaceView.presentationFrame) surface.frame = \(self.surfaceView.frame)")
|
|
} else {
|
|
os_log(msg, log: devLog, type: .debug, "activateLayout for \(state)")
|
|
}
|
|
}
|
|
|
|
// Must deactivate `interactiveTopConstraint` here
|
|
NSLayoutConstraint.deactivate(constraint: self.interactionConstraint)
|
|
self.interactionConstraint = nil
|
|
|
|
tearDownAttraction()
|
|
|
|
NSLayoutConstraint.activate(fixedConstraints)
|
|
|
|
if vc.contentMode == .fitToBounds {
|
|
NSLayoutConstraint.activate(constraint: self.fitToBoundsConstraint)
|
|
}
|
|
|
|
var state = state
|
|
|
|
if validStates.contains(state) == false {
|
|
state = layout.initialState
|
|
}
|
|
|
|
// Recalculate the intrinsic size of a content view. This is because
|
|
// UIView.systemLayoutSizeFitting() returns a different size between an
|
|
// on-screen and off-screen view which includes
|
|
// UIStackView(i.e. Settings view in Samples.app)
|
|
updateStateConstraints()
|
|
|
|
switch state {
|
|
case .hidden:
|
|
NSLayoutConstraint.activate(offConstraints)
|
|
default:
|
|
if let constraints = stateConstraints[state] {
|
|
NSLayoutConstraint.activate(constraints)
|
|
} else {
|
|
os_log(msg, log: sysLog, type: .fault, "Error: can not find any constraints for \(state)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func layoutSurfaceIfNeeded() {
|
|
#if !TEST
|
|
guard surfaceView.window != nil else { return }
|
|
#endif
|
|
surfaceView.superview?.layoutIfNeeded()
|
|
}
|
|
|
|
func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
|
|
return layout.backdropAlpha?(for: state) ?? defaultLayout.backdropAlpha(for: state)
|
|
}
|
|
|
|
fileprivate func checkLayout() {
|
|
// Verify layout configurations
|
|
assert(anchorStates.count > 0)
|
|
assert(validStates.contains(layout.initialState),
|
|
"Does not include an initial state (\(layout.initialState)) in (\(validStates))")
|
|
/* This assertion does not work in a device rotating
|
|
let statePosOrder = activeStates.sorted(by: { position(for: $0) < position(for: $1) })
|
|
assert(sortedDirectionalStates == statePosOrder,
|
|
"Check your layout anchors because the state order(\(statePosOrder)) must be (\(sortedDirectionalStates))).")
|
|
*/
|
|
}
|
|
}
|
|
|
|
extension LayoutAdapter {
|
|
func segment(at pos: CGFloat, forward: Bool) -> LayoutSegment {
|
|
/// ----------------------->Y
|
|
/// --> forward <-- backward
|
|
/// |-------|===o===|-------| |-------|-------|===o===|
|
|
/// |-------|-------x=======| |-------|=======x-------|
|
|
/// |-------|-------|===o===| |-------|===o===|-------|
|
|
/// pos: o/x, segment: =
|
|
|
|
let sortedStates = sortedAnchorStatesByCoordinate
|
|
|
|
let upperIndex: Int?
|
|
if forward {
|
|
upperIndex = sortedStates.firstIndex(where: { pos < position(for: $0) })
|
|
} else {
|
|
upperIndex = sortedStates.firstIndex(where: { pos <= position(for: $0) })
|
|
}
|
|
|
|
switch upperIndex {
|
|
case 0:
|
|
return LayoutSegment(lower: nil, upper: sortedStates.first)
|
|
case let upperIndex?:
|
|
return LayoutSegment(lower: sortedStates[upperIndex - 1], upper: sortedStates[upperIndex])
|
|
default:
|
|
return LayoutSegment(lower: sortedStates[sortedStates.endIndex - 1], upper: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension FloatingPanelController {
|
|
var _layout: FloatingPanelLayout {
|
|
get {
|
|
floatingPanel.layoutAdapter.layout
|
|
}
|
|
set {
|
|
floatingPanel.layoutAdapter.layout = newValue
|
|
floatingPanel.layoutAdapter.checkLayout()
|
|
}
|
|
}
|
|
}
|