SwiftUI est le nouveau framework de construction d'interface utilisateur d'Apple publié en 2019 dans le cadre de la mise à jour iOS13. Par rapport à l'ancien UIKit, SwiftUI est un framework déclaratif et fonctionnel, permettant aux développeurs de construire des interfaces utilisateur beaucoup plus rapidement, tout en offrant une boîte à outils très utile pour déboguer et tester le résultat grâce à des aperçus interactifs et à la prise en charge intégrée de Xcode.
Le parcours de DoorDash avec SwiftUI a nécessité de comprendre comment faire défiler les ScrollViews de manière programmatique à partir d'iOS13 afin d'ouvrir la voie à des fonctionnalités plus complexes par la suite, y compris des choses telles que l'ajout d'un comportement de " snapping-to-content ".
Les ScrollViews sont courants dans toute interface utilisateur moderne. Ils font partie de nombreux cas d'utilisation et d'interfaces utilisateur différents, permettant aux utilisateurs de découvrir beaucoup plus de contenu que ce qui tient normalement sur l'écran d'un appareil mobile ou dans la fenêtre d'affichage donnée d'un navigateur web.
Les ScrollViews, comme le montre la figure 1, sont étroitement intégrés dans nos conceptions pour nous permettre de concentrer l'attention de l'utilisateur sur un élément particulier. Nous pouvons les utiliser pour mettre en évidence la position ou la progression, aligner le contenu sur la fenêtre de visualisation et imposer un comportement d'accrochage, parmi une myriade d'autres avantages.
Avec notre nouvelle application SwiftUI, nous avons cherché à ajouter plusieurs de ces fonctionnalités à notre expérience de l'application. Malheureusement, la première version de SwiftUI en 2019, livrée avec iOS13, ne permettait pas de déplacer de manière programmatique les éléments suivants ScrollViews
qui n'a pu être réalisée qu'après la prochaine version de l'iOS14, un an plus tard.
Construire un défilement programmatique
Nous allons d'abord explorer les options de mise en œuvre de la vue de défilement avant de nous pencher sur la manière de mettre en œuvre le comportement de défilement programmatique.
Le défilement programmatique et ses avantages
Le défilement programmatique est la possibilité de donner des instructions à l'ordinateur pour qu'il se mette en marche. ScrollView
pour se déplacer vers une position particulière (appelée décalage de contenu) ou vers une vue cible.
Le défilement programmé a été introduit dans la première version d'iOS (iPhoneOS 2.0) au sein d'UIKit. Il existe des API publiquement disponibles pour l'accomplir : tous les UIScrollViews
et leurs sous-classes sont assorties des méthodes setContentOffset(:animated:)
et scrollRectToVisible(:animated:)
.
En raison de l'héritage d'UIKit, d'innombrables expériences utilisateur sont déjà en production et offrent un défilement programmatique ; il s'agit d'une fonctionnalité essentielle pour de nombreux flux d'utilisateurs. Par conséquent, il était tout à fait naturel d'exiger de tout nouveau framework d'interface utilisateur qu'il permette la même expérience utilisateur.
Pour citer quelques exemples, notre conception UX spécifie des scénarios dans lesquels une ScrollView
est positionné autour de certaines de ses vues secondaires, comme le défilement vers des sections incomplètes d'un formulaire avant que l'utilisateur ne puisse le soumettre :
De même, lorsque les positions de défilement de deux éléments sont liées et qu'ils défilent ensemble, comme dans le cas d'une liste principale et d'un carrousel horizontal :
Restez informé grâce aux mises à jour hebdomadaires
Abonnez-vous à notre blog d'ingénierie pour recevoir régulièrement des informations sur les projets les plus intéressants sur lesquels notre équipe travaille.
Veuillez saisir une adresse électronique valide.
Merci de vous être abonné !
SwiftUI 1.0 ScrollViews
n'offrait aucun support pour réaliser un défilement programmatique en général : il n'existait aucune API permettant de donner des instructions à l'application ScrollView
de se déplacer vers une position particulière.
En l'absence d'un support prêt à l'emploi, il nous a semblé plus opportun de conserver l'implémentation basée sur UIKit et de ne pas relever le défi supplémentaire que représente l'utilisation de SwiftUI. Cependant, la philosophie de DoorDash a toujours été d'explorer de nouvelles possibilités pour progresser vers notre objectif final et d'essayer de combler les lacunes que nous rencontrons en cours de route. Ce projet n'a pas fait exception à la règle.
Avant de montrer les étapes détaillées de notre enquête, nous aimerions noter que SwiftUI 2.0 a été livré avec un support programmatique supplémentaire pour iOS14, y compris l'introduction par Apple d'un ScrollViewReader et d'un ScrollViewProxy.
Le lecteur ScrollView est un conteneur transparent qui expose un argument proxy à son contenu. Les blocs de code du contenu peuvent utiliser ce proxy pour envoyer des messages à l'élément ScrollView
pour déplacer son décalage à une position particulière.
Cependant, ScrollViewReader
et son proxy ne sont pas rétrocompatibles avec iOS13/SwiftUI 1.0.
Cette amélioration nous a néanmoins inspirés pour la conception de notre propre API, notamment en ce qui concerne l'aspect du résultat final et la syntaxe la plus naturelle lors de la construction de notre interface utilisateur avec SwiftUI.
Construire des ScrollViews programmatiques avec SwiftUI
Pour construire quelque chose de ce genre par nous-mêmes, nous avons dû suivre les étapes suivantes :
- Exposer une référence à l'UIKit sous-jacent
UIScrollView
. Cette référence nous permet d'utiliser les API UIKit pour faire défiler le contenu de manière programmatique. - Construire des composants basés sur SwiftUI qui utilisent la référence ci-dessus afin que les développeurs puissent les utiliser pour donner des instructions à l'application
ScrollView
pour faire défiler. - Envelopper la solution dans une API pratique et facile à comprendre pour masquer sa complexité.
À titre de référence, voici un exemple de code rapide utilisant la solution d'Apple. Il s'agit d'un exemple de déclaration de vue simple démontrant l'utilisation d'un élément ScrollViewReader
. Le contenu du lecteur lui-même se voit attribuer un paramètre du type ScrollViewProxy
qui expose l'API permettant de faire défiler la page ScrollView
à toute vue enfant désignée en utilisant l'identifiant qui lui a été attribué :
import SwiftUI
struct ContentView: View {
enum ScrollPosition: Hashable {
case image(index: Int)
}
var body: some View {
ScrollView {
ScrollViewReader { proxy in
VStack {
ForEach(0..<10, content: logoImage)
Button {
withAnimation {
proxy.scrollTo(
ScrollPosition.image(index: 0),
anchor: .top
)
}
} label: {
Text("Scroll to top!")
}
.buttonStyle(.redButton)
}
}
}
}
func logoImage(at index: Int) -> some View {
Image("brand-logo")
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
.border(Color.red.opacity(0.5))
.padding()
.id(ScrollPosition.image(index: index))
}
}
Nous voulions nous assurer que les développeurs puissent utiliser une syntaxe familière, c'est pourquoi notre objectif était d'imiter cette construction.
Construire les composants pour le défilement programmatique
Pour mettre en œuvre tout type d'API programmatique personnalisée, nous avons dû demander au cadre de l'interface utilisateur de faire ce que nous voulions qu'il fasse.
En coulisses, les composants SwiftUI utilisent les anciennes vues UIKit comme blocs de construction ; en particulier ScrollViews
utilisent un système sous-jacent UIScrollView
. Si nous pouvions parcourir en toute sécurité la hiérarchie de vues UIKit que SwiftUI a automatiquement générée pour nous afin de trouver ce composant, nous pourrions utiliser les anciennes méthodes UIKit pour effectuer le défilement programmatique et atteindre notre objectif initial. En supposant que nous y parvenions, voici le chemin que nous devrions emprunter.
Sidenote: Il existe des bibliothèques tierces qui prétendent s'accrocher à l UIScrollView
mais d'après notre expérience, ils ne fonctionnent pas de manière fiable entre les différentes versions de SwiftUI. C'est pourquoi nous avons mis en place notre propre moyen de localiser cette référence.
Pour trouver une vue d'ensemble particulière - dans notre cas, la vue d'ensemble contenant des ScrollView
- dans l'arbre de vues UIKit, nous devons insérer une vue transparente qui n'affiche rien à l'utilisateur, mais qui est plutôt utilisée pour étudier la hiérarchie des vues. À cette fin, nous avons besoin d'une vue UIViewRepresentable
et un UIView
comme type de vue.
Une petite note pour le délestage des vélos sur les noms de types dans cet exemple : Nous appelons le remplacement du type Apple ScrollViewReader
"ScrollReader
et pour ScrollViewProxy
nous utilisons un protocole appelé ScrollProxyProtocol
et le type de l'objet que nous utilisons pour le mettre en œuvre en tant que __ScrollProxy
.
struct ScrollViewBackgroundReader: UIViewRepresentable {
let setProxy: (ScrollProxyProtocol) -> ()
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator()
setProxy(coordinator)
return coordinator
}
func makeUIView(context: Context) -> UIView {
UIView()
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
À l'avenir, la Coordinator
que nous avons ajouté dans le cadre de cette UIViewRepresentable fera le gros du travail pour nous, y compris la mise en œuvre des étapes nécessaires au défilement programmatique de l'élément UIScrollView
. Nous passons une fermeture du ScrollReader à la vue d'arrière-plan, de sorte que la fonction __ScrollProxy
peut déléguer des requêtes à l'outil Coordinator
. Vous pouvez voir sa mise en œuvre ci-dessous.
Nous pouvons ajouter cette vue de lecteur en arrière-plan du contenu de notre propre ScrollReader
:
struct ScrollReader<ScrollViewContent: View>: View {
private let content: (ScrollProxyProtocol) -> Content
private let proxy = __ScrollProxy()
init(@ViewBuilder content: @escaping (ScrollProxyProtocol) -> ScrollViewContent) {
self.content = content
}
var body: some View {
content(proxy)
.background(
ScrollViewBackgroundReader(setProxy: { proxy.other = $0 })
)
}
}
Tout au long de cet exemple, nous utilisons le .background()
pour ajouter notre opinions des lecteurs. Cela nous permet de les ajouter à la hiérarchie des vues. En outre, les composants .background() partagent la géométrie (position et taille) du récepteur, ce qui permet de trouver les coordonnées des différentes vues plus tard, lorsque nous devons traduire la position du contenu en CGPoint
coordonnées.
Ensuite, nous définissons notre ScrollProxyProtocol
:
protocol ScrollProxyProtocol {
/// Scrolls to a child view with the specified identifier.
///
/// - Parameter identifier: The unique scroll identifier of the child view.
/// - Parameter anchor: The unit point anchor to describe which edge of the child view
/// should be snap to.
func scroll(to identifier: AnyHashable, anchor: UnitPoint)
/// Scrolls to a child view with the specified identifier and adjusted by the offset position.
///
/// - Parameter identifier: The unique scroll identifier of the child view.
/// - Parameter anchor: The unit point anchor to describe which edge of the child view
/// should be snap to.
/// - Parameter offset: Extra offset on top of the identified view's position.
func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint)
}
Nous mettrons en œuvre ce protocole ci-dessus avec l'objet proxy (c'est-à-dire l'objet __ScrollProxy
) et séparément avec le Coordinator
du lecteur. Dans cette conception, l'objet proxy délègue les demandes de défilement à l'objet Coordinator
dans les coulisses. Les Coordinator’s
est transmise à l'objet proxy à l'aide de la méthode setProxy(_:)
fermeture utilisée ci-dessus.
struct ScrollReader: View {
...
private final class __ScrollProxy: ScrollProxyProtocol {
var other: ScrollProxyProtocol? // This is set to the Coordinator's instance
func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
other?.scroll(to: identifier, anchor: anchor)
}
func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
other?.scroll(to: identifier, anchor: anchor, offset: offset)
}
}
}
Notre Coordinator’s
du protocole proxy ressemblera à l'exemple de code ci-dessous - avec les éléments suivants TODO
pour l'instant :
final class Coordinator: MyScrollViewProxy {
...
private func locateTargetOffset(with identifier: AnyHashable, anchor: UnitPoint) -> (view: UIView, offset: CGPoint)? {
// TODO: locate target views with the given identifier
}
// MARK: ScrollProxyProtocol implementation
func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
// TODO: locate the view with the identifier, scroll its parent scrollview to the view’s position
}
func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
// TODO: locate the view with the identifier, scroll its parent scrollview to the view’s position
}
...
L'étape suivante consiste à localiser le décalage du contenu cible sur la base de l'identifiant de la vue et de l'ancre UnitPoint avant de localiser le bon UIScrollView
instance.
Ces deux tâches sont liées ; une fois que nous avons trouvé la vue de destination correcte, nous pouvons trouver le premier de ses parents, qui est également une vue de destination. UIScrollView
. Pour simplifier cette étape, nous avons ajouté une propriété calculée sur UIView
:
extension UIView {
var enclosingScrollView: UIScrollView? {
sequence(first: self, next: { $0.superview })
.first(where: { $0 is UIScrollView }) as? UIScrollView
}
}
Mais nous devons encore identifier et localiser la vue cible. Dans sa propre solution, Apple utilise l'interface SwiftUI .id()
pour identifier les vues de manière unique. Ce mécanisme est également utilisé dans leur solution de défilement programmatique.
Nous ne pouvons pas utiliser les résultats de cette API car elle est privée et nous est cachée. Ce que nous pouvons faire, c'est mettre en œuvre quelque chose de similaire.
Annoter les vues cibles
Nous utilisons ici le .background()
pour annoter les cibles de défilement potentielles avec un identifiant unique, tout en utilisant la vue du lecteur d'arrière-plan ci-dessus pour localiser les vues avec ces identifiants uniques. Pour ce faire, nous devons accomplir les tâches suivantes :
- Ajouter une API SwiftUI pour annoter les vues
- Ajouter un mécanisme de recherche pour trouver ces vues plus tard, lorsque nous aurons besoin de les faire défiler par programme.
- Convertir les placements de ces vues en
CGPoint
coordonnées de décalage du contenu
Pour la première étape, nous devons placer une autre UIViewRepresentable
dans la hiérarchie des vues à l'aide de l'option .background()
modificateur :
struct ScrollAnchorView: UIViewRepresentable {
let id: AnyHashable
func makeUIView(context: Context) -> ScrollAnchorBackgroundView {
let view = ScrollAnchorBackgroundView()
view.id = id
return view
}
func updateUIView(_ uiView: ScrollAnchorBackgroundView, context: Context) { }
final class ScrollAnchorBackgroundView: UIView {
var id: AnyHashable!
}
}
Nous ajoutons ensuite une méthode de commodité pour utiliser ce qui précède :
extension View {
/// Marks the given view as a potential scroll-to target for programmatic scrolling.
///
/// - Parameter id: An arbitrary unique identifier. Use this id in the scrollview reader's proxy
/// methods to scroll to this view.
func scrollAnchor(_ id: AnyHashable) -> some View {
background(ScrollAnchorView(id: id))
}
}
Nous avons veillé à ce que les UIViewRepresentable
et son UIView
partagent le même identifiant unique car la valeur de l'identifiant est spécifiée dans le domaine SwiftUI. Nous devrons cependant localiser l'élément UIView
avec le même ID dans la hiérarchie UIKit.
Nous pouvons utiliser les méthodes suivantes pour localiser l'unique UIView
dans la hiérarchie de la vue avec l'identifiant donné en utilisant une recherche récursive :
extension UIView {
func scrollAnchorView(with id: AnyHashable) -> UIView? {
for subview in subviews {
if let anchor = subview.asAnchor(with: id) ?? subview.scrollAnchorView(with: id) {
return anchor
}
}
return nil
}
private func asAnchor(with identifier: AnyHashable) -> UIView? {
guard let anchor = self as? ScrollAnchorView.ScrollAnchorBackgroundView, anchor.id == identifier else {
return nil
}
return anchor
}
}
Nous pouvons utiliser ces méthodes dans notre Coordinator’s locateTargetOffset
fonction. Immédiatement après, nous pouvons localiser le parent UIScrollView
également :
func locateTargetOffset(with identifier: AnyHashable, anchor: UnitPoint) -> (view: UIView, offset: CGPoint)? {
guard let targetView = backgroundReaderView.window?.scrollAnchorView(with: identifier) else { return nil }
guard let scrollView = targetView.enclosingScrollView else { return nil }
self.scrollView = scrollView
return (targetView, scrollView.convert(targetView.frame, from: targetView.superview).point(at: anchor, within: scrollView.bounds))
}
Cette méthode est appelée à partir du ScrollProxyProtocol
dans le cadre de notre Coordinator
:
func scroll(to identifier: AnyHashable, anchor: UnitPoint) {
guard let target = locateTargetOffset(with: identifier, anchor: anchor) else { return
scrollView?.setContentOffset(target.offset, animated: true)
}
func scroll(to identifier: AnyHashable, anchor: UnitPoint, offset: CGPoint) {
guard let target = locateTargetOffset(with: identifier, anchor: anchor) else { return }
scrollView?.setContentOffset(target.offset + offset, animated: true)
}
Nous avons surchargé l'opérateur + pour ajouter deux CGPoints
pour simplifier la syntaxe dans la dernière étape.
L'extrait de code suivant est utilisé pour convertir le fichier UIViews
et les limites de la UnitPoint
en coordonnées de décalage du contenu :
extension CGRect {
func point(at anchor: UnitPoint, within container: CGRect) -> CGPoint {
CGPoint(
x: minX + anchor.x * (width - container.width),
y: minY + anchor.y * (height - container.height)
)
}
}
À ce stade, nous avons terminé tout ce qu'il faut pour la première itération d'une solution de défilement programmatique :
- Ajout d'une API SwiftUI pour notre ScrollReader, qui à son tour est utilisé pour publier l'objet proxy afin de permettre un défilement programmatique.
- Localise la vue cible et son ScrollView parent et convertit le positionnement de la vue en paramètres d'entrée attendus par les API UIScrollView.
- Connexion de l'objet proxy avec l'implémentation concrète du coordinateur
Vous trouverez la solution sous forme de projet Swift ici.
Terminer le défilement programmatique
La construction décrite ci-dessus nous permet de reproduire le comportement de la SwiftUI ScrollViewReader
sans restriction de version iOS ou SwiftUI. Parce que nous avons un accès complet au système sous-jacent UIScrollView
En outre, nous pouvons utiliser cette solution pour ajouter des fonctionnalités supplémentaires à ce résultat initial, ce que nous explorerons dans des articles ultérieurs.
Mais même ce résultat initial présente une fonctionnalité supplémentaire : Il permet de faire défiler une vue à un point d'ancrage avec un décalage local appliqué pour un contrôle plus fin de la position de notre ScrollView
.
Nous pouvons maintenant reproduire le tout premier exemple avec notre propre solution :
struct MyContentView: View {
enum ScrollPosition: Hashable {
case image(index: Int)
}
var body: some View {
ScrollView {
ScrollReader { proxy in
VStack {
ForEach(0..<10, content: logoImage)
// as before - scrolling to an image
Button {
withAnimation {
proxy.scroll(to: ScrollPosition.image(index: 0), anchor: .top)
}
} label: {
Text("Scroll to the first image!")
}
}
.buttonStyle(.redButton)
}
}
}
func logoImage(at index: Int) -> some View {
Image("brand-logo")
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
.border(Color.red.opacity(0.5))
.padding()
.scrollAnchor(ScrollPosition.image(index: index))
}
}
Notez que nous avons remplacé le .id()
dans le logoImage
avec notre propre .scrollAnchor()
modificateur.
Et cela fonctionne comme prévu :
Le défaut de cette itération est l'absence de prise en charge des animations SwiftUI. Il n'existe pas de moyen simple de traduire les animations SwiftUI Animation
en utilisant les API de défilement d'UIKit. Nous explorerons une solution à ce problème dans un article ultérieur.
Conclusion
La construction du défilement programmatique dans SwiftUI a nécessité plusieurs itérations pour aboutir à un succès au fur et à mesure que nous progressions dans notre courbe d'apprentissage de SwiftUI. Dans sa forme actuelle, cependant, il est maintenant relativement facile à mettre en œuvre et peut être utilisé pour des cas d'utilisation simples dans tous les domaines et même pour des fonctionnalités de production.
Mais cette version n'est pas encore le résultat final. Nous avons réussi à pousser notre solution plus loin, en ajoutant la prise en charge des animations SwiftUI, le comportement d'accrochage au défilement pour la pagination, et d'autres comportements d'accrochage au contenu à grain fin, ainsi que la prise en charge de l'ajustement du taux de décélération.
Dans l'ensemble, ces étapes ont permis d'améliorer l'interface utilisateur SwiftUI ScrollView
pour nous permettre de l'utiliser pour les fonctionnalités de production tout en ouvrant la voie à notre migration pour réécrire notre application grand public en utilisant le framework SwiftUI sans compromis.
Dans des cas d'utilisation encore plus complexes, nous avons mis en place une collaboration entre notre composant modal de la feuille inférieure et un composant ScrollView
comme contenu, en traduisant ScrollView
les gestes de glisser-déposer en gestes qui déplacent la feuille elle-même, le cas échéant.
Un autre choix d'amélioration évident est la prise en charge de l'animation fine dans cette solution. Dans SwiftUI, les animations peuvent être spécifiées de manière très intuitive et directe, et sont plus puissantes que les choix offerts dans UIKit : ce dernier point est particulièrement vrai dans le cas de UIScrollView
API où l'on ne peut choisir que si l'on veut de l'animation ou pas du tout.
In subsequent posts, we will explain adding support for snapping behavior and deceleration rate, as well as how to change this iteration to enable SwiftUI animations. We'll revisit this problem in a later post.