Skip to content

Blog


Défilement programmatique avec SwiftUI ScrollView

21 juillet 2022

|
Zoltan Lippai

Zoltan Lippai

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.

Figure 1 : Le ScrollView est la zone située sous la vue à hauteur fixe d'une application mobile et peut, par le biais du défilement, donner accès à plus de contenu que la page n'en contiendrait autrement. 

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 ScrollViewsqui 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 :

Figure 2. Ce formulaire fait défiler la première section incomplète
avant de permettre à l'utilisateur d'enregistrer ses options

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.

Figure 3. Le carrousel horizontal en haut et le décalage de défilement principal du menu
sont liés : le défilement du menu principal met à jour le carrousel et l'appui sur l'une des étiquettes du carrousel fait défiler le menu principal.
sur l'une des étiquettes du carrousel fait défiler le menu principal.

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 :

  1. 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.
  2. 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.
  3. 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 ScrollViewProxyqui 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 UIScrollViewmais 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 "ScrollReaderet 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 UIViewset 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 UIScrollViewEn 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 :

Figure 4. Défilement programmatique à l'aide de notre propre solution. La première pression sur le bouton fait défiler le contenu jusqu'à la coordonnée d'origine de la première image du logo, la deuxième pression fait défiler le contenu 50 points au-dessus de la position supérieure du contenu.

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.

A propos de l'auteur

  • Zoltan Lippai

Emplois connexes

Localisation
Toronto, ON
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA
Département
Ingénierie
Localisation
San Francisco, CA ; Mountain View, CA ; New York, NY ; Seattle, WA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Seattle, WA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Seattle, WA
Département
Ingénierie