FloatingPanel 2.8.5

FloatingPanel 2.8.5

Shin Yamamoto维护。



Swift 5 Platform Version GitHub Workflow Status (with branch) Carthage compatible

FloatingPanel

FloatingPanel是一个简单易用的UI组件,旨在提供像苹果地图、快捷指令和股票应用那样的用户界面。该用户界面可以在主内容旁边显示相关内容和实用工具。

有关更多详细信息,请参阅API参考

Maps Stocks

Maps(Landscape)

特性

  • 简单的容器视图控制器
  • 使用数字弹簧的流畅行为
  • 滚动视图跟踪
  • 移除交互
  • 支持多面板
  • 模态呈现
  • 支持4个位置(顶部、左侧、底部、右侧)
  • 1个或多个磁性锚点(完整、一半、末端等)
  • 支持所有特征环境的布局(例如,横向方向)
  • 常见UI元素:表面、背景和抓取把手
  • 免受常见的自动布局和手势处理问题的影响
  • 与Objective-C兼容

示例请在此处查找

要求

FloatingPanel使用Swift 5.0+编写,与iOS 11.0+兼容。

虽然它仍然支持iOS 10,但建议在iOS 11+上使用此库。

✏️如果您要使用Swift 4.0,请使用FloatingPanel v1。

安装

CocoaPods

FloatingPanel可通过CocoaPods获取。要安装它,只需将以下行添加到您的Podfile中

pod 'FloatingPanel'

✏️FloatingPanel v1.7.0或更高版本需要CocoaPods v1.7.0+以支持swift_versions

Carthage

对于 Carthage,将以下内容添加到你的 Cartfile

github "scenee/FloatingPanel"

Swift Package Manager

参考此文档

入门

将浮動面板作為子視圖控制器添加

import UIKit
import FloatingPanel

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    var fpc: FloatingPanelController!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Initialize a `FloatingPanelController` object.
        fpc = FloatingPanelController()

        // Assign self as the delegate of the controller.
        fpc.delegate = self // Optional

        // Set a content view controller.
        let contentVC = ContentViewController()
        fpc.set(contentViewController: contentVC)

        // Track a scroll view(or the siblings) in the content view controller.
        fpc.track(scrollView: contentVC.tableView)

        // Add and show the views managed by the `FloatingPanelController` object to self.view.
        fpc.addPanel(toParent: self)
    }
}

以模態形式显示浮動面板

let fpc = FloatingPanelController()
let contentVC = ...
fpc.set(contentViewController: contentVC)

fpc.isRemovalInteractionEnabled = true // Optional: Let it removable by a swipe-down

self.present(fpc, animated: true, completion: nil)

您可以从容器视图控制器中显示浮动面板,作为.overCurrentContext样式的模式。

✏️浮动面板控制器具有自定义的展示控制器。如果您想要自定义展示/隐藏操作,请参阅过渡

视图层次结构

FloatingPanelController管理的视图层次结构如下。

FloatingPanelController.view (FloatingPanelPassThroughView)
 ├─ .backdropView (FloatingPanelBackdropView)
 └─ .surfaceView (FloatingPanelSurfaceView)
    ├─ .containerView (UIView)
    │  └─ .contentView (FloatingPanelController.contentViewController.view)
    └─ .grabber (FloatingPanelGrabberView)

用法

使用您的视图层次结构显示/隐藏浮动面板

如果您需要更细粒度地控制浮动面板的显示和隐藏,您可以直接调用addPanelremovePanelFromParent方法。这些方法是针对FloatingPanelshowhide方法以及一些必需设置的便利封装。

使用FloatingPanelController有两种方式:

  1. 只需将其加入层次结构一次,然后调用showhide方法,就可以让面板出现或消失。
  2. 当需要时将其加入层次结构,之后再移除。

以下示例展示了如何将控制器添加到您的UIViewController中以及如何将其移除。请确保在移除前不要将相同的FloatingPanelController加入层次结构中。

注意self.前缀不是必需的,也不建议使用。这里使用它是为了使使用到的函数来源更清晰。在您的代码中,self是您自定义UIViewController的一个实例。

// Add the floating panel view to the controller's view on top of other views.
self.view.addSubview(fpc.view)

// REQUIRED. It makes the floating panel view have the same size as the controller's view.
fpc.view.frame = self.view.bounds

// In addition, Auto Layout constraints are highly recommended.
// Constraint the fpc.view to all four edges of your controller's view.
// It makes the layout more robust on trait collection change.
fpc.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
  fpc.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0.0),
  fpc.view.leftAnchor.constraint(equalTo: self.view.leftAnchor, constant: 0.0),
  fpc.view.rightAnchor.constraint(equalTo: self.view.rightAnchor, constant: 0.0),
  fpc.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0.0),
])

// Add the floating panel controller to the controller hierarchy.
self.addChild(fpc)

// Show the floating panel at the initial position defined in your `FloatingPanelLayout` object.
fpc.show(animated: true) {
    // Inform the floating panel controller that the transition to the controller hierarchy has completed.
    fpc.didMove(toParent: self)
}

如您所看到,添加FloatingPanelController后,您可以调用fpc.show(animated: true) { }来显示面板,以及使用fpc.hide(animated: true) { }来隐藏它。

要从层次结构中移除FloatingPanelController,请参照以下示例。

// Inform the panel controller that it will be removed from the hierarchy.
fpc.willMove(toParent: nil)
    
// Hide the floating panel.
fpc.hide(animated: true) {
    // Remove the floating panel view from your controller's view.
    fpc.view.removeFromSuperview()
    // Remove the floating panel controller from the controller hierarchy.
    fpc.removeFromParent()
}

当表面位置变化时缩放内容视图

指定 contentMode.fitToBounds,如果表面高度适合 FloatingPanelController.view 的边界,当表面位置变化时

fpc.contentMode = .fitToBounds

否则,FloatingPanelController 会通过最顶部位置的高度固定内容。

✏️.fitToBounds 模式下,表面高度会根据用户交互变化,因此您有责任配置 Auto Layout 约束,以防止弹性表面高度破坏内容视图的布局。

使用 FloatingPanelLayout 协议自定义布局

更改初始布局

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ... {
        fpc = FloatingPanelController(delegate: self)
        fpc.layout = MyFloatingPanelLayout()
    }
}

class MyFloatingPanelLayout: FloatingPanelLayout {
    let position: FloatingPanelPosition = .bottom
    let initialState: FloatingPanelState = .tip
    let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
        .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea),
        .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea),
        .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .safeArea),
    ]
}

更新您的面板布局

有两种更新面板布局的方法。

  1. 直接手动设置 FloatingPanelController.layout 为新布局对象。
fpc.layout = MyPanelLayout()
fpc.invalidateLayout() // If needed

注意:如果您已经设置了 FloatingPanelController 实例的 delegate 属性,invalidateLayout() 将使用委托对象返回的布局对象覆盖 FloatingPanelController 的布局对象。

  1. 在两个 floatingPanel(_:layoutFor Nguyên) 委托中的一个中返回合适的布局对象。
class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
        return MyFloatingPanelLayout()
    }

    // OR
    func floatingPanel(_ vc: FloatingPanelController, layoutFor size: CGSize) -> FloatingPanelLayout {
        return MyFloatingPanelLayout()
    } 
}

支持你的景观布局

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout {
        return (newCollection.verticalSizeClass == .compact) ? LandscapePanelLayout() : FloatingPanelBottomLayout()
    }
}

class LandscapePanelLayout: FloatingPanelLayout {
    let position: FloatingPanelPosition = .bottom
    let initialState: FloatingPanelState = .tip
    let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
        .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .safeArea),
        .tip: FloatingPanelLayoutAnchor(absoluteInset: 69.0, edge: .bottom, referenceGuide: .safeArea),
    ]
    
    func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
        return [
            surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0),
            surfaceView.widthAnchor.constraint(equalToConstant: 291),
        ]
    }
}

在面板布局中使用内容的固有大小

  1. 使用固有高度大小来布局内容视图。例如,参见"详情视图控制器场景"/"固有视图控制器场景",在Main.storyboard中。'Stack View.bottom'约束决定了固有高度。
  2. 使用FloatingPanelIntrinsicLayoutAnchor指定布局锚点。
class IntrinsicPanelLayout: FloatingPanelLayout {
    let position: FloatingPanelPosition = .bottom
    let initialState: FloatingPanelState = .full
    let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
        .full: FloatingPanelIntrinsicLayoutAnchor(absoluteOffset: 0, referenceGuide: .safeArea),
        .half: FloatingPanelIntrinsicLayoutAnchor(fractionalOffset: 0.5, referenceGuide: .safeArea),
    ]
    ...
}

✏️ FloatingPanelIntrinsicLayout在v1版本已被弃用。

通过FloatingPanelController.view框的内边距指定每个状态的一个锚点

在锚点中使用.superview参考指南。

class MyFullScreenLayout: FloatingPanelLayout {
    ...
    let anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] = [
        .full: FloatingPanelLayoutAnchor(absoluteInset: 16.0, edge: .top, referenceGuide: .superview),
        .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .superview),
        .tip: FloatingPanelLayoutAnchor(absoluteInset: 44.0, edge: .bottom, referenceGuide: .superview),
    ]
}

✏️ FloatingPanelFullScreenLayout在v1版本已被弃用。

改变背景透明度

您可以通过每个状态(.full.half.tip)的 FloatingPanelLayout.backdropAlpha(for:) 方法改变背景透明度。

例如,如果面板看起来在.half状态时背景视图不存在,就是时候实现backdropAlpha API并为以下状态返回一个值了。

class MyPanelLayout: FloatingPanelLayout {
    func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
        switch state {
        case .full, .half: return 0.3
        default: return 0.0
        }
    }
}

使用自定义面板状态

您能够定义自定义面板状态并像以下示例那样使用它们。

extension FloatingPanelState {
    static let lastQuart: FloatingPanelState = FloatingPanelState(rawValue: "lastQuart", order: 750)
    static let firstQuart: FloatingPanelState = FloatingPanelState(rawValue: "firstQuart", order: 250)
}

class FloatingPanelLayoutWithCustomState: FloatingPanelBottomLayout {
    override var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] {
        return [
            .full: FloatingPanelLayoutAnchor(absoluteInset: 0.0, edge: .top, referenceGuide: .safeArea),
            .lastQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.75, edge: .bottom, referenceGuide: .safeArea),
            .half: FloatingPanelLayoutAnchor(fractionalInset: 0.5, edge: .bottom, referenceGuide: .safeArea),
            .firstQuart: FloatingPanelLayoutAnchor(fractionalInset: 0.25, edge: .bottom, referenceGuide: .safeArea),
            .tip: FloatingPanelLayoutAnchor(absoluteInset: 20.0, edge: .bottom, referenceGuide: .safeArea),
        ]
    }
}

使用FloatingPanelBehavior协议自定义行为

修改您的浮动面板交互

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func viewDidLoad() {
        ...
        fpc.behavior =  CustomPanelBehavior()
    }
}

class CustomPanelBehavior: FloatingPanelBehavior {
    let springDecelerationRate = UIScrollView.DecelerationRate.fast.rawValue + 0.02
    let springResponseTime = 0.4
    func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedTargetPosition: FloatingPanelState) -> Bool {
        return true
    }
}

✏️ floatingPanel(_ vc:behaviorFor:)在v1版本中已弃用。

在面板边缘激活橡皮筋效果

class MyPanelBehavior: FloatingPanelBehavior {
    ...
    func allowsRubberBanding(for edge: UIRectEdge) -> Bool {
        return true
    }
}

管理平移手势动量的投影

这允许完整的投影面板行为。例如,用户可以从尖端向上滑动面板到靠近尖端的整个位置。

class MyPanelBehavior: FloatingPanelBehavior {
    ...
    func shouldProjectMomentum(_ fpc: FloatingPanelController, to proposedTargetPosition: FloatingPanelPosition) -> Bool {
        return true
    }
}

指定面板移动的边界

FloatingPanelController.surfaceLocationfloatingPanelDidMove(_:) 委托方法中的行为类似于 UIScrollView.contentOffsetscrollViewDidScroll(_:) 中。因此,您可以按以下方式指定面板移动的边界。

func floatingPanelDidMove(_ vc: FloatingPanelController) {
    if vc.isAttracting == false {
        let loc = vc.surfaceLocation
        let minY = vc.surfaceLocation(for: .full).y - 6.0
        let maxY = vc.surfaceLocation(for: .tip).y + 6.0
        vc.surfaceLocation = CGPoint(x: loc.x, y: min(max(loc.y, minY), maxY))
    }
}

✏️ 从 v2 版本开始,{top,bottom}InteractionBuffer 属性已被从 FloatingPanelLayout 中移除。

自定义表面设计

修改你的表面外观

// Create a new appearance.
let appearance = SurfaceAppearance()

// Define shadows
let shadow = SurfaceAppearance.Shadow()
shadow.color = UIColor.black
shadow.offset = CGSize(width: 0, height: 16)
shadow.radius = 16
shadow.spread = 8
appearance.shadows = [shadow]

// Define corner radius and background color
appearance.cornerRadius = 8.0
appearance.backgroundColor = .clear

// Set the new appearance
fpc.surfaceView.appearance = appearance

使用自定义捕获器句柄

let myGrabberHandleView = MyGrabberHandleView()
fpc.surfaceView.grabberHandle.isHidden = true
fpc.surfaceView.addSubview(myGrabberHandleView)

自定义捕获器句柄布局

fpc.surfaceView.grabberHandlePadding = 10.0
fpc.surfaceView.grabberHandleSize = .init(width: 44.0, height: 12.0)

✏️请注意,《grabberHandleSize》宽度与高度在左右位置是倒置的。

自定义从表面边缘的内容内边距

fpc.surfaceView.contentPadding = .init(top: 20, left: 20, bottom: 20, right: 20)

自定义表面边缘的边距

fpc.surfaceView.containerMargins = .init(top: 20.0, left: 16.0, bottom: 16.0, right: 16.0)

此功能可应用于以下两种类型的面板

  • 类似Facebook/Slack的面板,其表面顶部边距与捕获器句柄分开。
  • 用于显示AirPods信息等的iOS原生面板。

自定义手势

阻止面板交互

您可以直接禁用平移手势识别器

fpc.panGestureRecognizer.isEnabled = false

或者使用这个 FloatingPanelControllerDelegate 方法。

func floatingPanelShouldBeginDragging(_ vc: FloatingPanelController) -> Bool {
    return aCondition ?  false : true
}

向表面视图添加点击手势

override func viewDidLoad() {
    ...
    let surfaceTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSurface(tapGesture:)))
    fpc.surfaceView.addGestureRecognizer(surfaceTapGesture)
    surfaceTapGesture.isEnabled = (fpc.position == .tip)
}

// Enable `surfaceTapGesture` only at `tip` state
func floatingPanelDidChangeState(_ vc: FloatingPanelController) {
    surfaceTapGesture.isEnabled = (vc.position == .tip)
}

中断 FloatingPanelController.panGestureRecognizer 的代理方法

如果您将 FloatingPanelController.panGestureRecognizer.delegateProxy 设置为继承自 UIGestureRecognizerDelegate 的对象,则它将覆盖平移手势识别器的代理方法。

class MyGestureRecognizerDelegate: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }
}

class ViewController: UIViewController {
    let myGestureDelegate = MyGestureRecognizerDelegate()

    func setUpFpc() {
        ....
        fpc.panGestureRecognizer.delegateProxy = myGestureDelegate
    }

为详细信息创建一个附加浮动面板

override func viewDidLoad() {
    // Setup Search panel
    self.searchPanelVC = FloatingPanelController()

    let searchVC = SearchViewController()
    self.searchPanelVC.set(contentViewController: searchVC)
    self.searchPanelVC.track(scrollView: contentVC.tableView)

    self.searchPanelVC.addPanel(toParent: self)

    // Setup Detail panel
    self.detailPanelVC = FloatingPanelController()

    let contentVC = ContentViewController()
    self.detailPanelVC.set(contentViewController: contentVC)
    self.detailPanelVC.track(scrollView: contentVC.scrollView)

    self.detailPanelVC.addPanel(toParent: self)
}

使用动画移动位置

以下示例中,我将浮动面板移至全位置或半位置,就像在苹果地图中打开或关闭搜索栏一样。

func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
    ...
    fpc.move(to: .half, animated: true)
}

func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
    ...
    fpc.move(to: .full, animated: true)
}

您还可以使用视图动画来移动面板。

UIView.animate(withDuration: 0.25) {
    self.fpc.move(to: .half, animated: false)
}

与浮动面板行为一起工作内容

class ViewController: UIViewController, FloatingPanelControllerDelegate {
    ...
    func floatingPanelWillBeginDragging(_ vc: FloatingPanelController) {
        if vc.position == .full {
            searchVC.searchBar.showsCancelButton = false
            searchVC.searchBar.resignFirstResponder()
        }
    }

    func floatingPanelWillEndDragging(_ vc: FloatingPanelController, withVelocity velocity: CGPoint, targetState: UnsafeMutablePointer<FloatingPanelState>) {
        if targetState.pointee != .full {
            searchVC.hideHeader()
        }
    }
}

启用背景视图的点击消失操作

默认情况下禁用点击消失操作。因此,需要按照以下步骤启用。

fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true

备注

FloatingPanelController 的内容视图控制器跳转到 '显示' 或 '显示详情' Segues

从内容视图控制器跳转到 '显示' 或 '显示详情' Segues 将由添加浮动面板的视图控制器(以下称为主 VC)来管理。因为浮动面板只是主 VC 的子视图(除模态之外)。

FloatingPanelController 没有像 UINavigationController 那样管理视图控制器堆栈的方法。如果是这样,那将非常复杂,并且界面将变成 UINavigationController。这个组件不应该承担管理堆栈的责任。

顺便说一句,内容视图控制器可以使用 present(_:animated:completion:) 方法或 '模态出现' Segues 以模态形式显示视图控制器。

然而,有时你想通过另一个浮动面板显示 '显示' 或 '显示详情' Segues 的目标视图控制器。你可以覆盖主 VC 中的 show(_:sender) 方法!

这里有一个例子。

class ViewController: UIViewController {
    var fpc: FloatingPanelController!
    var secondFpc: FloatingPanelController!

    ...
    override func show(_ vc: UIViewController, sender: Any?) {
        secondFpc = FloatingPanelController()

        secondFpc.set(contentViewController: vc)

        secondFpc.addPanel(toParent: self)
    }
}

FloatingPanelController 对象将 show(_:sender) 的动作代理给主 VC。这就是为什么主 VC 可以处理 '显示' 或 '显示详情' Segues 的目标视图控制器,并且你可以将 show(_:sender) 钩到显示二级浮动面板,并将目标视图控制器设置为内容。

这是一种将浮动面板与内容 VC 解耦的好方法。

UISearchController 问题

UISearchController 因系统设计的原因不能与 FloatingPanelController 一起使用。

因为 UISearchController 用户与搜索栏交互时,会自动以模态形式呈现自己,然后它在其显示时将其父视图切换到自身管理的视图。因此,FloatingPanelController 在其活动状态时不能控制搜索栏,正如您可以从以下链接中的截图看到这里

FloatingPanelSurfaceView的iOS 10问题

  • 在iOS 10上,由于UIVisualEffectView的问题,FloatingPanelSurfaceView.cornerRadius属性并不会自动为顶部边缘添加圆角。请参阅https://forums.developer.apple.com/thread/50854。因此,您需要为您的内容绘制顶部圆角。在Examples/Maps中有一个示例。
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    if #available(iOS 10, *) {
        visualEffectView.layer.cornerRadius = 9.0
        visualEffectView.clipsToBounds = true
    }
}
  • 如果您设置了FloatingPanelSurfaceView.backgroundColor的清除颜色,请注意当内容在全位置弹跳时的底部溢出情况。为了避免这种情况,您需要扩展您的内容。例如,请参阅Example/Maps应用程序在Main.storyboard中UIVisualEffectView的Auto Layout设置。

维护者

山本真 [email protected] | @scenee

许可证

FloatingPanel遵循MIT许可。有关更多信息,请参阅LICENSE文件。