我想知道如何使用 UIKit 制作类似于 Apple 地图中的浮动操作按钮。我特别坚持重新创建按钮的偏移随着半张纸的高度变化而动态变化的行为。
我通过将父视图控制器的
sheetPresentationController
设置为我的子视图控制器来呈现半张纸。我覆盖了孩子的 viewWillLayoutSubviews
以获取其视图的新高度,然后相应地设置浮动按钮的偏移量。
但是,我遇到了一个问题,如果用户提前放开纸张,按钮的偏移量就会“跳跃”。例如,我注意到,当用户提前放开时,视图的高度可能会从 200 跳到 500。这是不受欢迎的,因为我希望按钮的偏移看起来像苹果地图一样平滑变化。有没有办法获得更精细的孩子身高更新?
这里有一个 gif 展示了 Apple 地图中的行为:
这是一个 gif,显示了我的示例项目中的“跳跃行为”:
这是来自简化示例项目的代码:
class ViewController: UIViewController {
private var subscriptions = Set<AnyCancellable>()
private var buttonBottomConstraint = NSLayoutConstraint()
private lazy var floatingButton: UIButton = {
let button = UIButton()
button.setImage(
UIImage(systemName: "list.dash"), for: .normal
)
button.addTarget(
self,
action: #selector(openSheet),
for: .touchUpInside
)
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = .systemBackground.withAlphaComponent(0.95)
button.layer.cornerRadius = 20.0
button.layer.shadowColor = UIColor.black.cgColor
button.layer.shadowOpacity = 0.5
button.layer.shadowOffset = CGSize(width: 0, height: 2)
button.layer.shadowRadius = 4.0
button.layer.shouldRasterize = true
button.layer.rasterizationScale = UIScreen.main.scale
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
configureView()
}
private func configureView() {
view.backgroundColor = .systemMint
view.addSubview(floatingButton)
buttonBottomConstraint = floatingButton.bottomAnchor.constraint(
equalTo: view.bottomAnchor,
constant: -100
)
NSLayoutConstraint.activate([
buttonBottomConstraint,
floatingButton.trailingAnchor.constraint(
equalTo: view.trailingAnchor,
constant: -10
),
floatingButton.heightAnchor.constraint(equalToConstant: 50),
floatingButton.widthAnchor.constraint(equalToConstant: 50)
])
}
@objc
private func openSheet() {
let childViewController = ChildViewController()
configureChildViewControllerFrameSubscription(childViewController)
if let sheet = childViewController.sheetPresentationController {
sheet.detents = [.small(), .medium(), .large()]
sheet.largestUndimmedDetentIdentifier = .medium
sheet.prefersGrabberVisible = true
}
childViewController.isModalInPresentation = true
present(childViewController, animated: true)
}
}
extension ViewController {
private func configureChildViewControllerFrameSubscription(
_ childViewController: ChildViewController
) {
childViewController.framePassthroughSubject
.receive(on: DispatchQueue.main)
.sink { frame in
self.buttonBottomConstraint.isActive = false
self.buttonBottomConstraint = self.floatingButton.bottomAnchor.constraint(
equalTo: self.view.bottomAnchor,
constant: (frame.height * -1) - 10
)
self.buttonBottomConstraint.isActive = true
self.floatingButton.layoutIfNeeded()
}
.store(in: &subscriptions)
}
}
extension UISheetPresentationController.Detent.Identifier {
static var smallIdentifier: Self {
Self("small")
}
}
extension UISheetPresentationController.Detent {
static func small() -> UISheetPresentationController.Detent {
.custom(identifier: .smallIdentifier) { context in
context.maximumDetentValue * 0.15
}
}
}
class ChildViewController: UIViewController {
let framePassthroughSubject = PassthroughSubject<CGRect, Never>()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.backgroundColor = .systemBlue
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
framePassthroughSubject.send(view.frame)
}
}
是的,一旦用户放开,
viewDidLayoutSubviews
模式将无法优雅地处理动画。 (它并不能很好地处理任何动画。)它经常会导致这种不和谐的“跳转到最终位置”之类的 UI。
我们可以通过在按钮与这个新子视图之间添加约束来解决这个问题(并大大简化)(我会将其动画到位):
present(childViewController, animated: true) { [self] in
UIView.animate(withDuration: 0.25) {
childViewController.view.topAnchor.constraint(
equalTo: floatingButton.bottomAnchor,
constant: 10
).isActive = true
floatingButton.layoutIfNeeded()
}
}
显然,我们希望降低按钮与主视图之间现有约束的优先级,以便它可以优雅地优先考虑这个新约束,从而避免约束冲突:
let buttonBottomConstraint = floatingButton.bottomAnchor.constraint(
equalTo: view.bottomAnchor,
constant: -10
)
buttonBottomConstraint.priority = .defaultHigh // rather than the default of .required
NSLayoutConstraint.activate([
buttonBottomConstraint,
floatingButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
floatingButton.heightAnchor.constraint(equalToConstant: 50),
floatingButton.widthAnchor.constraint(equalToConstant: 50)
])
这将使约束系统在子视图出现之前使用它
buttonBottomConstraint
,但一旦子视图出现,就让新约束优先。
(顺便说一句,由于我们将让约束系统为您处理所有这些,
buttonBottomConstraint
不再需要是一个属性,而可以只是一个局部变量。)
无论如何,一旦你这样做了,你就可以(并且应该)消除所有手动更新框架的组合代码。
约束系统将负责将该按钮固定到子视图,当用户拖动子视图高度时,以及当他们放开它时,它会动画到某个最终的效果,从而无缝地使按钮与子视图保持固定距离。位置。