J'ai publié une bibliothèque basée sur ma réponse ci-dessous.
Il imite la superposition d'application des raccourcis. Consultez cet article pour plus de détails.
Le composant principal de la bibliothèque est le OverlayContainerViewController
. Il définit une zone dans laquelle un contrôleur de vue peut être déplacé de haut en bas, masquant ou révélant le contenu en dessous.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Mettre OverlayContainerViewControllerDelegate
en œuvre pour spécifier le nombre d'encoches souhaité:
enum OverlayNotch: Int, CaseIterable {
case minimum, medium, maximum
}
func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
return OverlayNotch.allCases.count
}
func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
heightForNotchAt index: Int,
availableSpace: CGFloat) -> CGFloat {
switch OverlayNotch.allCases[index] {
case .maximum:
return availableSpace * 3 / 4
case .medium:
return availableSpace / 2
case .minimum:
return availableSpace * 1 / 4
}
}
Réponse précédente
Je pense qu'il y a un point important qui n'est pas traité dans les solutions proposées: la transition entre le parchemin et la traduction.
Dans Maps, comme vous l'avez peut-être remarqué, lorsque la tableView atteint contentOffset.y == 0
, la feuille inférieure glisse vers le haut ou vers le bas.
Le point est délicat car nous ne pouvons pas simplement activer / désactiver le défilement lorsque notre geste de panoramique commence la traduction. Cela arrêterait le défilement jusqu'à ce qu'une nouvelle touche commence. C'est le cas dans la plupart des solutions proposées ici.
Voici mon essai de mettre en oeuvre cette motion.
Point de départ: l'application Maps
Pour commencer notre enquête, nous allons visualiser la hiérarchie de la vue des cartes (début Maps sur un simulateur et sélectionnez Debug
> Attach to process by PID or Name
> Maps
dans Xcode 9).
Cela ne dit pas comment fonctionne la motion, mais cela m'a aidé à en comprendre la logique. Vous pouvez jouer avec le lldb et le débogueur de hiérarchie de vues.
Nos piles de contrôleur de vue
Créons une version de base de l'architecture Maps ViewController.
Nous commençons par un BackgroundViewController
(notre vue de la carte):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Nous mettons la tableView dans un espace dédié UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Maintenant, nous avons besoin d'un VC pour intégrer la superposition et gérer sa traduction. Pour simplifier le problème, nous considérons qu'il peut traduire la superposition d'un point statique OverlayPosition.maximum
à un autre OverlayPosition.minimum
.
Pour l'instant, il n'a qu'une seule méthode publique pour animer le changement de position et il a une vue transparente:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Enfin, nous avons besoin d'un ViewController pour intégrer le tout:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
Dans notre AppDelegate, notre séquence de démarrage ressemble à:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
La difficulté derrière la traduction de superposition
Maintenant, comment traduire notre superposition?
La plupart des solutions proposées utilisent un outil de reconnaissance des mouvements panoramiques dédié, mais nous en avons déjà un: le mouvement panoramique de la vue tableau. De plus, nous devons garder le défilement et la traduction synchronisés et UIScrollViewDelegate
tous les événements dont nous avons besoin!
Une implémentation naïve utiliserait un deuxième mouvement panoramique et tenterait de réinitialiser la vue contentOffset
de la table lorsque la traduction se produit:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Mais ça ne marche pas. La tableView met à jour son contentOffset
lorsque sa propre action de reconnaissance de mouvement panoramique se déclenche ou lorsque son rappel displayLink est appelé. Il n'y a aucune chance que notre module de reconnaissance se déclenche juste après ceux pour réussir à remplacer le contentOffset
. Notre seule chance est soit de participer à la phase de mise en page (en remplaçant layoutSubviews
les appels de la vue de défilement à chaque image de la vue de défilement), soit de répondre à la didScroll
méthode du délégué appelé à chaque contentOffset
modification de la. Essayons celui-ci.
L'implémentation de la traduction
Nous ajoutons un délégué à notre OverlayVC
pour envoyer les événements de scrollview à notre gestionnaire de traduction, le OverlayContainerViewController
:
protocol OverlayViewControllerDelegate: class {
func scrollViewDidScroll(_ scrollView: UIScrollView)
func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}
class OverlayViewController: UIViewController {
[...]
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
delegate?.scrollViewDidStopScrolling(scrollView)
}
}
Dans notre conteneur, nous suivons la traduction à l'aide d'une énumération:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Le calcul de la position actuelle ressemble à ceci:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Nous avons besoin de 3 méthodes pour gérer la traduction:
Le premier nous indique si nous devons commencer la traduction.
private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
guard scrollView.isTracking else { return false }
let offset = scrollView.contentOffset.y
switch overlayInFlightPosition {
case .maximum:
return offset < 0
case .minimum:
return offset > 0
case .progressing:
return true
}
}
Le second effectue la traduction. Il utilise la translation(in:)
méthode du mouvement panoramique du scrollView.
private func translateView(following scrollView: UIScrollView) {
scrollView.contentOffset = .zero
let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
translatedViewHeightContraint.constant = max(
Constant.minimumHeight,
min(translation, Constant.maximumHeight)
)
}
Le troisième anime la fin de la traduction lorsque l'utilisateur relâche son doigt. Nous calculons la position en utilisant la vitesse et la position actuelle de la vue.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
L'implémentation des délégués de notre superposition ressemble simplement à:
class OverlayContainerViewController: UIViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldTranslateView(following: scrollView) else { return }
translateView(following: scrollView)
}
func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
// prevent scroll animation when the translation animation ends
scrollView.isEnabled = false
scrollView.isEnabled = true
animateTranslationEnd()
}
}
Dernier problème: répartir les touches du conteneur de superposition
La traduction est maintenant assez efficace. Mais il y a encore un dernier problème: les touches ne sont pas apportées à notre vue d'arrière-plan. Ils sont tous interceptés par la vue du conteneur de superposition. Nous ne pouvons pas mettre isUserInteractionEnabled
à false
parce qu'il désactiverait aussi l'interaction dans notre présentation de tableau. La solution est celle qui est utilisée massivement dans l'application Maps, PassThroughView
:
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return nil
}
return view
}
}
Il se supprime de la chaîne répondeur.
Dans OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Résultat
Voici le résultat:
Vous pouvez trouver le code ici .
S'il vous plaît, si vous voyez des bugs, faites le moi savoir! Notez que votre implémentation peut bien sûr utiliser un deuxième geste de panoramique, spécialement si vous ajoutez un en-tête dans votre superposition.
Mise à jour 23/08/18
Nous pouvons remplacer scrollViewDidEndDragging
par
willEndScrollingWithVelocity
plutôt que enabling
/ disabling
le défilement lorsque l'utilisateur termine de glisser:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Nous pouvons utiliser une animation de ressort et permettre l'interaction de l'utilisateur pendant l'animation pour améliorer le flux de mouvement:
func moveOverlay(to position: OverlayPosition,
duration: TimeInterval,
velocity: CGPoint) {
overlayPosition = position
translatedViewHeightContraint.constant = translatedViewTargetHeight
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
initialSpringVelocity: abs(velocity.y),
options: [.allowUserInteraction],
animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}