Je pense qu'il y a un point important qui n'est pas traitée dans les solutions proposées : la transition entre le livre et la traduction.
Dans les Cartes, comme vous l'avez remarqué, lors de la tableView atteint contentOffset.y == 0
, le bas de la feuille, soit de glisse ou tombe en panne.
Le point est délicat car nous ne pouvons pas simplement d'activer/désactiver le défilement lors de notre poêle geste commence la traduction. Il pourrait arrêter le défilement jusqu'à ce qu'une touche nouvelle commence. C'est le cas dans la plupart des solutions proposées ici.
Voici ma essayer de mettre en œuvre cette motion.
Point de départ : Application Maps
Pour commencer notre enquête, nous allons visualiser le point de vue de la hiérarchie de Cartes (démarrer Cartes sur un simulateur et sélectionnez Debug
> Attach to process by PID or Name
> Maps
dans Xcode).
Il ne dit pas comment le mouvement fonctionne, mais il m'a aidé à comprendre la logique. Vous pouvez jouer avec les lldb et le point de vue de la hiérarchie débogueur.
Notre ViewController piles
Nous allons créer une version de base, les Cartes ViewController architecture.
Nous commençons avec un BackgroundViewController
(notre map) :
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Nous avons mis la tableView dans un 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 de gérer sa traduction.
Pour simplifier le problème, nous considérons qu'il peut traduire la superposition d'un point statique OverlayPosition.maximum
autre OverlayPosition.minimum
.
Pour l'instant, on 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 incorporer 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 superposition de traduction
Maintenant, comment traduire notre overlay ?
La plupart des solutions proposées utilisez un pan geste de reconnaissance, mais nous avons en fait déjà un : le pan geste de la vue de la table.
En outre, nous avons besoin de garder le défilement et la traduction synchronisée et l' UIScrollViewDelegate
a tous les événements dont nous avons besoin !
Une implémentation naïve serait d'utiliser un deuxième pan Geste et essayez de réinitialiser l' contentOffset
de l'affichage de la table quand la traduction se produit :
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Mais il ne fonctionne pas. La tableView les mises à jour de ses contentOffset
lorsque sa propre pan geste de reconnaissance de l'action des déclencheurs ou lorsque son displayLink callback est appelée. Il n'y a aucune chance que notre reconnaissance déclenche juste après ceux pour réussir à remplacer l' contentOffset
.
Notre seule chance c'est de prendre une partie de l'aménagement de la phase (en substituant layoutSubviews
de défilement de la vue des appels à chaque image de défilement de la vue) ou pour répondre à l' didScroll
méthode de le délégué a appelé à chaque fois que l' contentOffset
est modifié. Nous allons essayer celui-ci.
La traduction de la mise en Œuvre
Nous avons ajouter un délégué à notre OverlayVC
d'expédition de la scrollview les événements de notre traduction du gestionnaire, l' 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 gardons la trace de la traduction à l'aide d'un enum :
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
La position actuelle de calcul ressemble :
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 dit que si nous avons besoin de 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
}
}
La seconde effectue la traduction. Il utilise l' translation(in:)
méthode de la scrollView pan geste.
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 le doigt. Nous calculons la position à l'aide de la vitesse et de la position actuelle de la vue.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Notre superposition de déléguer la mise en œuvre 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 : l'expédition de la superposition du conteneur de touche
La traduction est maintenant assez efficace. Mais il reste un dernier problème : les touches ne sont pas livrés à nos arrière-plan de la vue. Ils sont tous interceptés par la superposition du conteneur de vue.
Nous ne pouvons pas définir isUserInteractionEnabled
de false
parce qu'il serait aussi désactiver l'interaction dans notre tableau. La solution est celle qui est utilisée massivement dans l'application Cartes, 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 retire du répondeur de la chaîne.
En OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Résultat
Voici le résultat :
Vous trouverez le code ici.
S'il vous plaît si vous voyez des bugs, faites le moi savoir ! Notez que votre application peut bien sûr utiliser un deuxième pan geste, spécialement si vous ajoutez un en-tête dans votre superposition.
Mise à jour 23/08/18
Nous pouvons remplacer scrollViewDidEndDragging
avec
willEndScrollingWithVelocity
plutôt que d' enable
/disable
le défilement lorsque l'utilisateur termine le faisant 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)
}
On peut utiliser un printemps de l'animation et de permettre une interaction de l'utilisateur tandis que l'animation de rendre le mouvement de flux de mieux :
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)
}