Как сделать плавающие кнопки, подобные тем, что есть в Apple Maps?

Я хочу знать, как создать плавающие кнопки действий, подобные тем, что есть в 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)
  }
}
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
0
97
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Да, шаблон 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
        })
    }
}

Это приводит к:

Ого, спасибо большое за подробный ответ! Это была большая помощь. В принципе это ответ на мой вопрос, но знаете ли вы, как предотвратить подпрыгивание кнопки при открытии листа? В идеале его смещение должно меняться с той же скоростью, что и представление листа.

rizz 26.04.2024 00:46

Если вы хотите что-то согласовать с презентацией, вы реализуете transitionCoordinator?.animate(alongsideTransition:) в viewDidAppear ребенка. Я обновил свой MRE, приведенный выше, чтобы проиллюстрировать закономерность.

Rob 26.04.2024 03:23

Удивительно, спасибо! Я принял ваш ответ и искренне ценю вашу помощь. Если я превращаю кнопку в переключатель (представляю лист, если он не представлен, закрываю его, если он представлен), я замечаю, что ограничение не соблюдается при воспроизведении анимации закрытия (нижняя часть снова прыгает). Знаете, почему это так?

rizz 26.04.2024 06:22

Теоретически вы должны быть в состоянии сделать подобное transitionCoordinator в viewWillDisappear, но я думаю, что есть более глубокая проблема. Я заметил, что после закрытия при следующем представлении он больше не анимируется. Странный. Простое решение — не отклонять, а просто установить UISheetPresentationController.Detent на ноль. Затем он нормально выключается и снова включается. См. gist.github.com/robertmryan/f880d2ddfebbafee75601cf0a02fcb23‌​.

Rob 26.04.2024 17:36

После дополнительного расследования выяснилось, что вам нужно вызвать setNeedsLayout() прямо перед layoutIfNeeded() как для презентации, так и для увольнения. Это заставило меня избавиться от прыжков.

rizz 29.04.2024 03:19

Можно ли этого добиться с помощью SwiftUI?

matarali 03.06.2024 19:38

Другие вопросы по теме