web-dev-qa-db-fra.com

SwiftUI ScrollView: Comment modifier .content.offset aka Paging?

Problème

Comment puis-je modifier la cible de défilement d'une scrollView? Je cherche une sorte de remplacement pour la méthode déléguée scrollView "classique"

override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

... où nous pouvons modifier le scrollView.contentOffset ciblé via targetContentOffset.pointee par exemple pour créer un comportement de pagination personnalisé.

Ou en d'autres termes: je veux créer un effet de pagination dans un scrollView (horizontal).

Ce que j'ai essayé c'est à dire. est quelque chose comme ça:

    ScrollView(.horizontal, showsIndicators: true, content: {
            HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
                card(title: "1")
                card(title: "2")
                card(title: "3")
                card(title: "4")
            })
     })
    // 3.
     .content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
    // 4.
     .animation(self.dragState.isDragging == true ? nil : Animation.spring())
    // 5.
    .gesture(horizontalDragGest)

Tentative

Voici ce que j'ai essayé (en plus d'une approche scrollView personnalisée):

  1. Un scrollView a une zone de contenu plus grande que l'espace d'écran pour permettre le défilement.

  2. J'ai créé une DragGesture() pour détecter s'il y a un glissement en cours. Dans les fermetures .onChanged et .onEnded, j'ai modifié mes valeurs @State Pour créer le scrollTarget souhaité.

  3. Alimentation conditionnelle à la fois des valeurs d'origine inchangées et des nouvelles valeurs modifiées dans le modificateur .content.offset (x: y :) - en fonction du dragState en remplacement des méthodes scrollDelegate manquantes.

  4. Ajout d'une animation agissant conditionnellement uniquement lorsque le glissement est terminé.

  5. Attaché le geste au scrollView.

Longue histoire courte. Ça ne marche pas. J'espère avoir compris quel est mon problème.

Des solutions? Dans l'attente de toute entrée. Merci!

17
HelloTimo

J'ai réussi à obtenir un comportement de pagination avec un @Binding index. La solution peut sembler sale, je vais vous expliquer mes solutions de contournement.

La première chose que je me suis trompée, c'est d'avoir un alignement sur .leading au lieu de la valeur par défaut .center, sinon le décalage fonctionne de manière inhabituelle. J'ai ensuite combiné la liaison et un état de décalage local. Cela va un peu à l'encontre du principe de la "source unique de vérité", mais sinon je n'avais aucune idée de la façon de gérer les changements d'index externes et de modifier mon décalage.

Donc, mon code est le suivant

struct SwiftUIPagerView<Content: View & Identifiable>: View {

    @Binding var index: Int
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    // 1
    var pages: [Content]

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        page
                            .frame(width: geometry.size.width, height: nil)
                    }
                }
            }
            // 2
            .content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
            // 3
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture().onChanged({ value in
                // 4
                self.isGestureActive = true
                // 5
                self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
            }).onEnded({ value in
                if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
                    self.index += 1
                }
                if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
                    self.index -= 1
                }
                // 6
                withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                // 7
                DispatchQueue.main.async { self.isGestureActive = false }
            }))
        }
    }
}
  1. vous pouvez simplement envelopper votre contenu, je l'ai utilisé pour les "Vues du didacticiel".
  2. c'est une astuce pour basculer entre les changements d'état externes et internes
  3. .leading est obligatoire si vous ne voulez pas traduire tous les décalages au centre.
  4. définir l'état au changement d'état local
  5. calculer le décalage complet à partir du delta du geste (* -1) plus l'état d'index précédent
  6. à la fin, définissez l'index final en fonction de la fin prévue du geste, tout en arrondissant le décalage vers le haut ou vers le bas
  7. réinitialiser l'état pour gérer les modifications externes de l'index

Je l'ai testé dans le contexte suivant

    struct WrapperView: View {

        @State var index: Int = 0

        var body: some View {
            VStack {
                SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })

                Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
                    ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
            }
        }
    }

TODOView est ma vue personnalisée qui indique une vue à implémenter.

J'espère avoir bien répondu à la question, sinon veuillez préciser sur quelle partie je dois me concentrer. Je souhaite également toute suggestion de suppression de l'état isGestureActive.

9
gujci

@gujci, merci pour cet exemple intéressant. J'ai joué avec et j'ai supprimé l'état isGestureActive. Un exemple complet peut être trouvé dans mon Gist .

struct SwiftUIPagerView<Content: View & Identifiable>: View {

    @State private var index: Int = 0
    @State private var offset: CGFloat = 0

    var pages: [Content]

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        page
                            .frame(width: geometry.size.width, height: nil)
                    }
                }
            }
            .content.offset(x: self.offset)
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture()
                .onChanged({ value in
                    self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
                })
                .onEnded({ value in
                    if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
                        var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
                        nextIndex += self.index
                        self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
                    }
                    withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                })
            )
        }
    }
}
1
Alex.O

Une solution alternative serait d'intégrer UIKit dans SwiftUI en utilisant UIViewRepresentative qui relie les composants UIKit à SwiftUI. Pour des pistes et des ressources supplémentaires, voyez comment Apple vous suggère d'interfacer avec UIKit: Interfaçage avec UIKit . Ils ont un bon exemple qui montre à la page entre les images et l'index de sélection de piste .

Edit: jusqu'à ce qu'ils (Apple) implémentent une sorte de décalage de contenu qui affecte le défilement au lieu de la vue entière, c'est leur solution suggérée car ils savaient que la version initiale de SwiftUI ne comprendrait pas toutes les fonctionnalités d'UIKit.

0
Kyle Beard

Pour autant que je sache, les parchemins dans swiftUI ne prennent pas encore en charge quoi que ce soit d'utile comme scrollViewDidScroll ou scrollViewWillEndDragging. Je suggère d'utiliser des vues UIKit classiques pour créer un comportement très personnalisé et des vues SwiftUI sympas pour tout ce qui est plus facile. J'ai beaucoup essayé et ça marche! Jetez un oeil à ce guide . J'espère que cela pourra aider

0
glassomoss

@gujci, votre solution est parfaite, pour une utilisation plus générale, faites-la accepter les modèles et le générateur de vue comme dans (notez que je passe la taille de la géométrie dans le générateur):

struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {

    @Binding var index: Int
    @State private var offset: CGFloat = 0
    @State private var isGestureActive: Bool = false

    // 1
    var pages: [TModel]
    var builder : (CGSize, TModel) -> TView

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(self.pages) { page in
                        self.builder(geometry.size, page)
                    }
                }
            }
            // 2
            .content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
            // 3
            .frame(width: geometry.size.width, height: nil, alignment: .leading)
            .gesture(DragGesture().onChanged({ value in
                // 4
                self.isGestureActive = true
                // 5
                self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
            }).onEnded({ value in
                if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
                    self.index += 1
                }
                if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
                    self.index -= 1
                }
                // 6
                withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
                // 7
                DispatchQueue.main.async { self.isGestureActive = false }
            }))
        }
    }
}

et peut être utilisé comme:

    struct WrapperView: View {

        @State var index: Int = 0
        @State var items : [(color:Color,name:String)] = [
            (.red,"Red"),
            (.green,"Green"),
            (.yellow,"Yellow"),
            (.blue,"Blue")
        ]
        var body: some View {
            VStack(spacing: 0) {

                SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
                    TODOView(extraInfo: item.model.name)
                        .frame(width: size.width, height: size.height)
                        .background(item.model.color)
                }

                Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
                    ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
                }
                .pickerStyle(SegmentedPickerStyle())

            }.edgesIgnoringSafeArea(.all)
        }
    }

avec l'aide de certains utilitaires:

    struct MakeIdentifiable<TModel,TID:Hashable> :  Identifiable {
        var id : TID {
            return idetifier(model)
        }
        let model : TModel
        let idetifier : (TModel) -> TID
    }
    extension Array {
        func identify<TID: Hashable>(by: @escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
        {
            return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
        }
    }
0
Ala'a Al Hallaq