Я хочу знать, как создать плавающие кнопки действий, подобные тем, что есть в Apple Maps, с помощью UIKit. Я специально застрял на воссоздании поведения, при котором смещение кнопок изменяется динамически при изменении высоты половины листа.
Я представляю половину листа, устанавливая родительский контроллер представления sheetPresentationController на мой дочерний контроллер представления. Я переопределил viewWillLayoutSubviews дочернего элемента, чтобы получить новую высоту его представления, а затем соответствующим образом установил смещение плавающих кнопок.
Однако я столкнулся с проблемой, когда смещение кнопок «подскакивает», если пользователь раньше отпускает лист. Я заметил, что когда пользователь отпускает раньше времени, высота представления может подскочить, например, с 200 до 500. Это нежелательно, поскольку я бы предпочел, чтобы смещение кнопок менялось плавно, как в Apple Maps. Есть ли способ получить более детальную информацию о росте ребенка?
Вот гифка, демонстрирующая поведение в Apple Maps:
Вот гифка, показывающая «прыжковое поведение» в моем примере проекта:
Вот код из упрощенного примера проекта:
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 не будет корректно обрабатывать анимацию, как только пользователь ее отпустит. (На самом деле он не очень хорошо обрабатывает анимацию.) Это часто приводит к появлению неприятного пользовательского интерфейса типа «переход к конечному местоположению». В итоге мы хотим избежать обновления местоположения кнопки вручную и вместо этого позволить системе макета сделать это за нас.
Например, мы можем решить эту проблему (и значительно упростить), добавив ограничение между кнопкой и этим новым подпредставлением (и я анимирую его на месте):
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 больше не обязательно должно быть свойством, а может быть просто локальной переменной.)
В любом случае, как только вы это сделаете, вы сможете (и должны) удалить весь код объединения, который вручную обновлял кадр.
Система ограничений позаботится о прикреплении этой кнопки к подпредставлению, плавно удерживая кнопку на фиксированном расстоянии от дочернего представления, когда пользователь перетаскивает высоту дочернего представления, а также когда он отпускает ее, и она анимируется в каком-то конечном месте. . Это также решает проблему задержки расположения кнопки, когда пользователь также перетаскивает дескриптор дочернего представления.
Кстати, вот моя последняя версия вашего MRE:
class ViewController: UIViewController {
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()
configureView()
}
private func configureView() {
view.backgroundColor = .systemMint
view.addSubview(floatingButton)
let buttonBottomConstraint = floatingButton.bottomAnchor.constraint(
equalTo: view.bottomAnchor,
constant: -10
)
buttonBottomConstraint.priority = .defaultHigh
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()
if let sheet = childViewController.sheetPresentationController {
sheet.detents = [.small(), .medium(), .large()]
sheet.largestUndimmedDetentIdentifier = .medium
sheet.prefersGrabberVisible = true
}
childViewController.isModalInPresentation = true
childViewController.animateAlongside = { [self] _ in
NSLayoutConstraint.activate([
childViewController.view.topAnchor.constraint(equalTo: floatingButton.bottomAnchor, constant: 10)
])
view.layoutIfNeeded()
}
present(childViewController, animated: true)
}
}
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 {
var animateAlongside: ((any UIViewControllerTransitionCoordinatorContext) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
transitionCoordinator?.animate(alongsideTransition: { [self] context in
animateAlongside?(context)
animateAlongside = nil
})
}
}
Это приводит к:
Если вы хотите что-то согласовать с презентацией, вы реализуете transitionCoordinator?.animate(alongsideTransition:) в viewDidAppear ребенка. Я обновил свой MRE, приведенный выше, чтобы проиллюстрировать закономерность.
Удивительно, спасибо! Я принял ваш ответ и искренне ценю вашу помощь. Если я превращаю кнопку в переключатель (представляю лист, если он не представлен, закрываю его, если он представлен), я замечаю, что ограничение не соблюдается при воспроизведении анимации закрытия (нижняя часть снова прыгает). Знаете, почему это так?
Теоретически вы должны быть в состоянии сделать подобное transitionCoordinator в viewWillDisappear, но я думаю, что есть более глубокая проблема. Я заметил, что после закрытия при следующем представлении он больше не анимируется. Странный. Простое решение — не отклонять, а просто установить UISheetPresentationController.Detent на ноль. Затем он нормально выключается и снова включается. См. gist.github.com/robertmryan/f880d2ddfebbafee75601cf0a02fcb23.
После дополнительного расследования выяснилось, что вам нужно вызвать setNeedsLayout() прямо перед layoutIfNeeded() как для презентации, так и для увольнения. Это заставило меня избавиться от прыжков.
Можно ли этого добиться с помощью SwiftUI?
Ого, спасибо большое за подробный ответ! Это была большая помощь. В принципе это ответ на мой вопрос, но знаете ли вы, как предотвратить подпрыгивание кнопки при открытии листа? В идеале его смещение должно меняться с той же скоростью, что и представление листа.