// // Created by Shin Yamamoto on 2018/09/27. // Copyright © 2018 Shin Yamamoto. All rights reserved. // import UIKit /// FloatingPanelIntrinsicLayout /// /// - Attention: /// `insetFor(position:)` must return `nil` for full position because the inset is determined automatically. /// You can customize insets only for half, tip and hidden positions /// on FloatingPanelIntrinsicLayout. public protocol FloatingPanelIntrinsicLayout: FloatingPanelLayout { } public extension FloatingPanelIntrinsicLayout { var initialPosition: FloatingPanelPosition { return .full } var supportedPositions: Set { return [.full] } func insetFor(position: FloatingPanelPosition) -> CGFloat? { return nil } } public protocol FloatingPanelLayout: class { /// Returns the initial position of a floating panel. var initialPosition: FloatingPanelPosition { get } /// Returns a set of FloatingPanelPosition objects to tell the applicable /// positions of the floating panel controller. /// /// By default, it returns all position exepct for `hidden` position. Because /// it's always supported by `FloatingPanelController` so you don't need to return it. var supportedPositions: Set { get } /// Return the interaction buffer to the top from the top position. Default is 6.0. var topInteractionBuffer: CGFloat { get } /// Return the interaction buffer to the bottom from the bottom position. Default is 6.0. var bottomInteractionBuffer: CGFloat { get } /// Returns a CGFloat value to determine a Y coordinate of a floating panel for each position(full, half, tip and hidden). /// /// Its returning value indicates a different inset for each positiion. /// For full position, a top inset from a safe area in `FloatingPanelController.view`. /// For half or tip position, a bottom inset from the safe area. /// For hidden position, a bottom inset from `FloatingPanelController.view`. /// If a position isn't supported or the default value is used, return nil. func insetFor(position: FloatingPanelPosition) -> CGFloat? /// Returns X-axis and width layout constraints of the surface view of a floating panel. /// You must not include any Y-axis and height layout constraints of the surface view /// because their constraints will be configured by the floating panel controller. /// By default, the width of a surface view fits a safe area. func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] /// Returns a CGFloat value to determine the backdrop view's alpha for a position. /// /// Default is 0.3 at full position, otherwise 0.0. func backdropAlphaFor(position: FloatingPanelPosition) -> CGFloat } public extension FloatingPanelLayout { var topInteractionBuffer: CGFloat { return 6.0 } var bottomInteractionBuffer: CGFloat { return 6.0 } var supportedPositions: Set { return Set([.full, .half, .tip]) } func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] { return [ surfaceView.leftAnchor.constraint(equalTo: view.sideLayoutGuide.leftAnchor, constant: 0.0), surfaceView.rightAnchor.constraint(equalTo: view.sideLayoutGuide.rightAnchor, constant: 0.0), ] } func backdropAlphaFor(position: FloatingPanelPosition) -> CGFloat { return position == .full ? 0.3 : 0.0 } } public class FloatingPanelDefaultLayout: FloatingPanelLayout { public var contentViewController: UIViewController? public var initialPosition: FloatingPanelPosition { return .half } public func insetFor(position: FloatingPanelPosition) -> CGFloat? { switch position { case .full: return 18.0 case .half: return 262.0 case .tip: return 69.0 case .hidden: return nil } } } public class FloatingPanelDefaultLandscapeLayout: FloatingPanelLayout { public var contentViewController: UIViewController? public var initialPosition: FloatingPanelPosition { return .tip } public var supportedPositions: Set { return [.full, .tip] } public func insetFor(position: FloatingPanelPosition) -> CGFloat? { switch position { case .full: return 16.0 case .tip: return 69.0 default: return nil } } } class FloatingPanelLayoutAdapter { weak var vc: UIViewController! private weak var surfaceView: FloatingPanelSurfaceView! private weak var backdropView: FloatingPanelBackdropView! var layout: FloatingPanelLayout var safeAreaInsets: UIEdgeInsets = .zero { didSet { if oldValue != safeAreaInsets { updateHeight() } } } private var heightBuffer: CGFloat = 88.0 // For bounce private var fixedConstraints: [NSLayoutConstraint] = [] private var fullConstraints: [NSLayoutConstraint] = [] private var halfConstraints: [NSLayoutConstraint] = [] private var tipConstraints: [NSLayoutConstraint] = [] private var offConstraints: [NSLayoutConstraint] = [] private var heightConstraints: [NSLayoutConstraint] = [] private var fullInset: CGFloat { if layout is FloatingPanelIntrinsicLayout { return intrinsicHeight } else { return layout.insetFor(position: .full) ?? 0.0 } } private var halfInset: CGFloat { return layout.insetFor(position: .half) ?? 0.0 } private var tipInset: CGFloat { return layout.insetFor(position: .tip) ?? 0.0 } private var hiddenInset: CGFloat { return layout.insetFor(position: .hidden) ?? 0.0 } var supportedPositions: Set { var supportedPositions = layout.supportedPositions supportedPositions.remove(.hidden) return supportedPositions } var topY: CGFloat { if supportedPositions.contains(.full) { if layout is FloatingPanelIntrinsicLayout { return surfaceView.superview!.bounds.height - (safeAreaInsets.bottom + fullInset) } else { return (safeAreaInsets.top + fullInset) } } else { return middleY } } var middleY: CGFloat { return surfaceView.superview!.bounds.height - (safeAreaInsets.bottom + halfInset) } var bottomY: CGFloat { if supportedPositions.contains(.tip) { return surfaceView.superview!.bounds.height - (safeAreaInsets.bottom + tipInset) } else { return middleY } } var hiddenY: CGFloat { return surfaceView.superview!.bounds.height } var safeAreaBottomY: CGFloat { return surfaceView.superview!.bounds.height - (safeAreaInsets.bottom + hiddenInset) } var topMaxY: CGFloat { return -safeAreaInsets.top } var bottomMaxY: CGFloat { return safeAreaBottomY } var adjustedContentInsets: UIEdgeInsets { return UIEdgeInsets(top: 0.0, left: 0.0, bottom: safeAreaInsets.bottom, right: 0.0) } func positionY(for pos: FloatingPanelPosition) -> CGFloat { switch pos { case .full: return topY case .half: return middleY case .tip: return bottomY case .hidden: return hiddenY } } var intrinsicHeight: CGFloat = 0.0 init(surfaceView: FloatingPanelSurfaceView, backdropView: FloatingPanelBackdropView, layout: FloatingPanelLayout) { self.layout = layout self.surfaceView = surfaceView self.backdropView = backdropView } func updateIntrinsicHeight() { let fittingSize = UIView.layoutFittingCompressedSize intrinsicHeight = surfaceView.contentView.systemLayoutSizeFitting(fittingSize).height } func prepareLayout(in vc: UIViewController) { self.vc = vc surfaceView.translatesAutoresizingMaskIntoConstraints = false backdropView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.deactivate(fixedConstraints + fullConstraints + halfConstraints + tipConstraints + offConstraints) // Fixed constraints of surface and backdrop views let surfaceConstraints = layout.prepareLayout(surfaceView: surfaceView, in: vc.view!) 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 // Flexible surface constarints for full, half, tip and off if layout is FloatingPanelIntrinsicLayout { updateIntrinsicHeight() fullConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.layoutGuide.bottomAnchor, constant: -fullInset), ] } else { fullConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.layoutGuide.topAnchor, constant: fullInset), ] } halfConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.layoutGuide.bottomAnchor, constant: -halfInset), ] tipConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.layoutGuide.bottomAnchor, constant: -tipInset), ] offConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.view.bottomAnchor, constant: -hiddenInset), ] } // The method is separated from prepareLayout(to:) for the rotation support // It must be called in FloatingPanelController.traitCollectionDidChange(_:) func updateHeight() { guard let vc = vc else { return } defer { UIView.performWithoutAnimation { surfaceView.superview!.layoutIfNeeded() } } NSLayoutConstraint.deactivate(heightConstraints) let height: CGFloat if layout is FloatingPanelIntrinsicLayout { updateIntrinsicHeight() height = intrinsicHeight + safeAreaInsets.bottom } else { // Must use the`vc` height, not the screen height because safe area insets // of `vc` are relative values. For example, a view controller in // Navigation controller's safe area insets and frame can be changed whether // the navigation bar is translucent or not. height = vc.view.bounds.height - (safeAreaInsets.top + fullInset) } heightConstraints = [ surfaceView.heightAnchor.constraint(equalToConstant: height) ] NSLayoutConstraint.activate(heightConstraints) surfaceView.set(bottomOverflow: heightBuffer + layout.topInteractionBuffer) if layout is FloatingPanelIntrinsicLayout { NSLayoutConstraint.deactivate(fullConstraints) fullConstraints = [ surfaceView.topAnchor.constraint(equalTo: vc.layoutGuide.bottomAnchor, constant: -fullInset), ] NSLayoutConstraint.activate(fullConstraints) } } func activateLayout(of state: FloatingPanelPosition) { defer { surfaceView.superview!.layoutIfNeeded() } var state = state setBackdropAlpha(of: state) NSLayoutConstraint.activate(fixedConstraints) if supportedPositions.union([.hidden]).contains(state) == false { state = layout.initialPosition } NSLayoutConstraint.deactivate(fullConstraints + halfConstraints + tipConstraints + offConstraints) switch state { case .full: NSLayoutConstraint.deactivate(halfConstraints + tipConstraints + offConstraints) NSLayoutConstraint.activate(fullConstraints) case .half: NSLayoutConstraint.deactivate(fullConstraints + tipConstraints + offConstraints) NSLayoutConstraint.activate(halfConstraints) case .tip: NSLayoutConstraint.deactivate(fullConstraints + halfConstraints + offConstraints) NSLayoutConstraint.activate(tipConstraints) case .hidden: NSLayoutConstraint.deactivate(fullConstraints + halfConstraints + tipConstraints) NSLayoutConstraint.activate(offConstraints) } } func setBackdropAlpha(of target: FloatingPanelPosition) { if target == .hidden { self.backdropView.alpha = 0.0 } else { self.backdropView.alpha = layout.backdropAlphaFor(position: target) } } func checkLayoutConsistance() { // Verify layout configurations assert(supportedPositions.count > 0) assert(supportedPositions.contains(layout.initialPosition), "Does not include an initial potision(\(layout.initialPosition)) in supportedPositions(\(supportedPositions))") if layout is FloatingPanelIntrinsicLayout { assert(layout.insetFor(position: .full) == nil, "Return `nil` for full position on FloatingPanelIntrinsicLayout") } if halfInset > 0 { assert(halfInset > tipInset, "Invalid half and tip insets") } if fullInset > 0 { assert(middleY > topY, "Invalid insets { topY: \(topY), middleY: \(middleY) }") assert(bottomY > topY, "Invalid insets { topY: \(topY), bottomY: \(bottomY) }") } } }