SwiftUI sur les étagères: Animation. Partie 1

image

Récemment, je suis tombé sur un nouvel article dans lequel les gars essayaient de reproduire un concept intéressant en utilisant SwiftUI. Voici ce qu'ils ont fait:

image

j'ai étudié leur code avec intérêt, mais j'ai vécu une certaine frustration. Non, pas dans le sens où ils ont fait quelque chose de mal, pas du tout. Je n'ai tout simplement pas appris de leur code pratiquement rien de nouveau. Leur implémentation concerne plus la combinaison que l'animation. Et j'ai décidé de construire mon lunopark pour écrire mon article sur l'animation dans SwiftUI, en mettant en œuvre le même concept, mais en utilisant 100% des capacités de l'animation intégrée, même si elle n'est pas très efficace. Pour étudier - jusqu'à la fin. Pour expérimenter - donc avec un clin d'œil :)

Voici ce que j'ai obtenu:


Cependant, pour une divulgation complète du sujet, j'ai dû parler en détail des bases mêmes. Le texte s'est avéré volumineux et, par conséquent, je l'ai divisé en deux articles. Voici la première partie de celui-ci - plutôt, un tutoriel sur l'animation en général, pas directement lié à l'animation arc-en-ciel, dont je parlerai en détail dans le prochain article.

Dans cet article, je vais parler des bases, sans lesquelles vous pouvez facilement vous perdre dans des exemples plus complexes. Une grande partie de ce dont je vais parler, sous une forme ou une autre, a déjà été décrite dans des articles en anglais tels que cette série ( 1 , 2 , 3 , 4) Pour ma part, je me suis concentré non pas tant sur l'énumération des méthodes de travail que sur la description exacte de son fonctionnement. Et comme toujours, j'ai beaucoup expérimenté, donc je m'empresse de partager les résultats les plus intéressants.

avertissement: sous le chat il y a beaucoup d'images et d'animations gif.

TLDR


Le projet est disponible sur github . Vous pouvez voir le résultat actuel avec une animation arc-en-ciel dans TransitionRainbowView (), mais je ne me précipiterais pas à votre place, mais j'ai attendu le prochain article. De plus, lors de sa préparation, je peigne un peu le code.

Dans cet article, nous aborderons uniquement les bases et n'affecterons que le contenu du dossier Bases.

introduction


J'avoue, je n'allais pas écrire cet article maintenant. J'avais un plan selon lequel un article sur l'animation était censé être le troisième, voire le quatrième d'affilée. Cependant, je n'ai pas pu résister, je voulais vraiment apporter un point de vue alternatif.

Je veux faire une réservation tout de suite. Je ne pense pas que des erreurs aient été commises dans l'article mentionné, ou l'approche utilisée dans celui-ci est incorrecte. Pas du tout. Il construit un modèle objet du processus (animation) qui, répondant au signal reçu, commence à faire quelque chose. Cependant, pour moi, cet article révèle très probablement un travail avec le framework Combine. Oui, ce framework est une partie importante de SwiftUI, mais il s'agit plus d'un style de réaction en général que d'animation.

Mon option n'est certainement pas plus élégante, plus rapide et plus facile à entretenir. Cependant, il révèle beaucoup mieux ce qui se trouve sous le capot de SwiftUI, et c'était d'ailleurs le but de l'article - de le comprendre en premier.

Comme je l'ai dit dans un article précédentpar SwiftUI, j'ai commencé ma plongée dans le monde du développement mobile tout de suite avec SwiftUI, en ignorant UIKit. Bien sûr, cela a un prix, mais il y a des avantages. Je n'essaie pas de vivre dans un nouveau monastère selon l'ancienne charte. Honnêtement, je ne connais pas encore de chartes, donc je n'ai pas de rejet de la nouvelle. C'est pourquoi, cet article, il me semble, peut être utile non seulement pour les débutants, comme moi, mais aussi pour ceux qui étudient SwiftUI ayant déjà des antécédents en matière de développement sur UIKit. Il me semble que beaucoup de gens manquent d'un nouveau regard. Ne faites pas la même chose, essayez d'intégrer un nouvel outil dans les anciens dessins, mais changez votre vision en fonction de nouvelles possibilités.

Nous 1c-nicks sommes passés par là avec des «formes contrôlées». C'est une sorte de SwiftUI dans le monde des 1, qui s'est produit il y a plus de 10 ans. En fait, l'analogie est assez précise, car les formulaires gérés ne sont qu'une nouvelle façon de dessiner une interface. Cependant, il a complètement changé l'interaction client-serveur de l'application dans son ensemble, et l'image du monde dans l'esprit des développeurs en particulier. Ce n’était pas facile, moi-même je ne voulais pas l’étudier pendant environ 5 ans, car Je pensais que beaucoup des opportunités qui y étaient fermées étaient simplement nécessaires pour moi. Mais, comme la pratique l'a montré, le codage sur les formulaires gérés est non seulement possible, mais seulement nécessaire.

Mais n'en parlons plus. J'ai obtenu un guide détaillé et indépendant qui n'a pas de références, ou d'autres liens avec l'article mentionné ou le 1er passé. Étape par étape, nous allons plonger dans les détails, les fonctionnalités, les principes et les limites. Aller.

Forme animée


Fonctionnement de l'animation en général


Ainsi, l'idée principale de l'animation est la transformation d'un changement particulier et discret en un processus continu. Par exemple, le rayon du cercle était de 100 unités, est devenu 50 unités. Sans animation, le changement se fera instantanément, avec animation - en douceur. Comment ça fonctionne? Très simple. Pour des changements en douceur, nous devons interpoler plusieurs valeurs dans le segment «C'était ... c'est devenu». Dans le cas du rayon, nous devrons dessiner plusieurs cercles intermédiaires avec un rayon de 98 unités, 95 unités, 90 unités ... 53 unités et, enfin, 50 unités. SwiftUI peut le faire facilement et naturellement, il suffit d'encapsuler le code qui effectue ce changement dans withAnimation {...}. Cela semble magique ... Jusqu'à ce que vous vouliez implémenter quelque chose d'un peu plus compliqué que "bonjour le monde".

Passons aux exemples. L'objet d'animation le plus simple et le plus compréhensible est considéré comme une animation de formes. La forme (j'appellerai toujours la structure conforme au protocole de forme de forme) dans SwiftUI est une structure avec des paramètres qui peuvent s'insérer dans ces limites. Ceux. c'est une structure qui a le corps de la fonction (en rect: CGRect) -> Path. Tout ce dont le runtime a besoin pour dessiner ce formulaire est de demander son contour (le résultat de la fonction est un objet de type Path, en fait, c'est une courbe de Bézier) pour la taille requise (spécifiée comme paramètre de fonction, un rectangle de type CGRect).

La forme est une structure stockée. En l'initialisant, vous stockez dans les paramètres tout ce dont vous avez besoin pour dessiner son contour. La taille de la sélection pour ce formulaire peut changer, alors tout ce qui est nécessaire est d'obtenir une nouvelle valeur Path pour la nouvelle trame CGRect, et le tour est joué.

Commençons déjà le codage:

struct CircleView: View{
    var radius: CGFloat
    var body: some View{
        Circle()
            .fill(Color.green)
            .frame(height: self.radius * 2)
            .overlay(
                Text("Habra")
                    .font(.largeTitle)
                    .foregroundColor(.gray)
                )

    }
}
struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}
struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
            CircleView(radius: radius)
               .frame(height: 200)
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


Nous avons donc un cercle (Circle ()), dont le rayon peut être modifié à l'aide du curseur. Cela se passe bien, car le curseur nous donne toutes les valeurs intermédiaires. Cependant, lorsque vous cliquez sur le bouton "définir le rayon par défaut", le changement ne se produit pas non plus instantanément, mais selon l'instruction withAnimation (.linear (duration: 1)). Linéairement, sans accélération, étiré pendant 1 seconde. Classe! Nous avons maîtrisé l'animation! Nous ne sommes pas d'accord :)

Mais que faire si nous voulons implémenter notre propre formulaire et animer ses changements? Est-ce difficile de faire ça? Allons vérifier.

J'ai fait une copie de Circle comme suit:

struct CustomCircle: Shape{
    public func path(in rect: CGRect) -> Path{
        let radius = min(rect.width, rect.height) / 2
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        return Path(){path in
            if rect.width > rect.height{
                path.move(to: CGPoint(x: center.x, y: 0))
                let startAngle = Angle(degrees: 270)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }else{
                path.move(to: CGPoint(x: 0, y: center.y))
                let startAngle = Angle(degrees: 0)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }
            path.closeSubpath()
        }
    }
}

Le rayon du cercle est calculé comme la moitié de la plus petite de la largeur et de la hauteur de la bordure de la zone d'écran qui nous est attribuée. Si la largeur est supérieure à la hauteur, nous partons du milieu de la bordure supérieure (note 1), décrivons le cercle complet dans le sens des aiguilles d'une montre (note 2) et fermons notre contour à ce sujet. Si la hauteur est supérieure à la largeur, nous partons du milieu de la bordure droite, nous décrivons également le cercle complet dans le sens horaire et fermons le contour.

Note 1
Apple ( ) . , (0, 0), (x, y), x — , y — . .. y. y — . , . 90 , 180 — , 270 — .

Note 2
1 , “ ” “ ” . Core Graphics (SwiftUI ):
In a flipped coordinate system (the default for UIView drawing methods in iOS), specifying a clockwise arc results in a counterclockwise arc after the transformation is applied.

Vérifions comment notre nouveau cercle réagira aux modifications du bloc withAnimation:

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}

struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
                HStack{
                CircleView(radius: radius)
                    .frame(height: 200)
                CustomCircleView(radius: radius)
                    .frame(height: 200)
            }
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


Hou la la! Nous avons appris à créer nos propres images de forme libre et à les animer! Il en est ainsi?

Pas vraiment. Tout le travail ici est effectué par le modificateur .frame (largeur: self.radius * 2, hauteur: self.radius * 2). À l'intérieur du bloc withAnimation {...} nous changeonsEtatune variable, il envoie un signal pour réinitialiser CustomCircleView () avec une nouvelle valeur de rayon, cette nouvelle valeur tombe dans le modificateur .frame (), et ce modificateur peut déjà animer les changements de paramètres. Notre formulaire CustomCircle () réagit à cela avec une animation, car cela ne dépend pas d'autre chose que de la taille de la zone sélectionnée. Le changement de zone se produit avec l'animation, (c'est-à-dire progressivement, interpolant les valeurs intermédiaires entre elles était-est devenue), donc notre cercle est dessiné avec la même animation.

Simplifions (ou compliquons encore?) Notre forme un peu. Nous ne calculerons pas le rayon en fonction de la taille de la zone disponible, mais nous transférerons le rayon sous la forme finie, c'est-à-dire en faire un paramètre de structure stocké.

struct CustomCircle: Shape{
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
        //let radius = min(rect.width, rect.height) / 2
...
    }
}

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle(radius: radius)
            .fill(Color.gray)
            //.frame(height: self.radius * 2)
...
    }
}


Eh bien, la magie est irrémédiablement perdue.

Nous avons exclu le modificateur frame () de notre CustomCircleView (), déplaçant la responsabilité de la taille du cercle sur la forme elle-même, et l'animation a disparu. Mais cela n'a pas d'importance; apprendre à un formulaire à animer les changements de ses paramètres n'est pas trop difficile. Pour ce faire, vous devez implémenter les exigences du protocole Animatable:

struct CustomCircle: Shape, Animatable{
    var animatableData: CGFloat{
         get{
             radius
         }
         set{
            print("new radius is \(newValue)")
            radius = newValue
         }
     }
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
	...
}


Voila! La magie est de retour!

Et maintenant, nous pouvons dire avec confiance que notre formulaire est vraiment animé - il peut refléter les changements de ses paramètres avec l'animation. Nous avons donné au système une fenêtre où il peut entasser les valeurs interpolées nécessaires à l'animation. S'il existe une telle fenêtre, les modifications sont animées. Si ce n'est pas le cas, les modifications ont lieu sans animation, c'est-à-dire immédiatement. Rien de compliqué, non?

AnimatableModifier


Comment animer des modifications dans une vue


Mais allons directement à View. Supposons que nous voulons animer la position d'un élément à l'intérieur d'un conteneur. Dans notre cas, ce sera un simple rectangle de couleur verte et une largeur de 10 unités. Nous animerons sa position horizontale.

struct SimpleView: View{
    @State var position: CGFloat = 0
    var body: some View{
        VStack{
            ZStack{
                Rectangle()
                    .fill(Color.gray)
                BorderView(position: position)
            }
            Slider(value: self.$position, in: 0...1)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.position = 0
                }
            }){
                Text("set to 0")
            }
        }
    }
}

struct BorderView: View,  Animatable{
    public var animatableData: CGFloat {
        get {
            print("Reding position: \(position)")
            return self.position
        }
        set {
            self.position = newValue
            print("setting position: \(position)")
        }
    }
    let borderWidth: CGFloat
    init(position: CGFloat, borderWidth: CGFloat = 10){
        self.position = position
        self.borderWidth = borderWidth
        print("BorderView init")
    }
    var position: CGFloat
    var body: some View{
        GeometryReader{geometry in
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
                // .borderIn(position: position)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        print("calculating position: \(position)")
        return -inSize.width / 2 + inSize.width * position
    }
}


Classe! Travaux! Maintenant, nous savons tout sur l'animation!

Pas vraiment. Si vous regardez la console, nous verrons ce qui suit:
Position de
calcul de l' init BorderView : 0,4595176577568054
Position de
calcul de l' init BorderView: 0,468130886554718
Position de
calcul de l' init BorderView : 0,0

Tout d'abord, chaque modification de la valeur de position à l'aide du curseur entraîne la réinitialisation de BorderView avec la nouvelle valeur. C'est pourquoi nous voyons un mouvement régulier de la ligne verte après le curseur, le curseur signale simplement très souvent un changement dans la variable, et cela ressemble à une animation, mais ce n'est pas le cas. L'utilisation du curseur est très pratique lorsque vous déboguez une animation. Vous pouvez l'utiliser pour suivre certains états de transition.

Deuxièmement, nous voyons que la position de calcul est simplement devenue égale à 0, et aucun journal intermédiaire, comme ce fut le cas avec l'animation correcte du cercle. Pourquoi?

La chose, comme dans l'exemple précédent, est dans le modificateur. Cette fois, le modificateur .offset () obtient la nouvelle valeur de retrait, et il anime le changement lui-même. Ceux. en fait, ce n'est pas le changement du paramètre de position que nous voulions animer, mais le changement horizontal du retrait dans le modificateur .offset () qui en dérive. Dans ce cas, il s'agit d'un remplacement inoffensif, le résultat est le même. Mais depuis qu'ils sont venus, creusons plus profondément. Faisons notre propre modificateur, qui recevra la position (de 0 à 1) à l'entrée, il recevra la taille de la zone disponible et calculera le retrait.

struct BorderPosition: ViewModifier{
    var position: CGFloat
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
            .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
            .animation(nil)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        let offset = -inSize.width / 2 + inSize.width * position
        print("at position  \(position) offset is \(offset)")
        return offset
    }
}

extension View{
    func borderIn(position: CGFloat) -> some View{
        self.modifier(BorderPosition(position: position))
    }
}

Dans le BorderView d'origine, respectivement, le GeometryReader n'est plus nécessaire, ainsi que la fonction de calcul du retrait:

struct BorderView: View,  Animatable{
    ...
    var body: some View{
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .borderIn(position: position)
    }
}


Oui, nous utilisons toujours le modificateur .offset () dans notre modificateur, mais après cela, nous avons ajouté le modificateur .animation (nil), qui bloque notre propre animation de décalage. Je comprends qu'à ce stade, vous pouvez décider qu'il suffit de retirer ce verrou, mais nous n'irons pas au fond de la vérité. Et la vérité est que notre astuce avec animatableData pour BorderView ne fonctionne pas. En fait, si vous regardez la documentation du protocole Animatable , vous remarquerez que l'implémentation de ce protocole n'est prise en charge que pour AnimatableModifier, GeometryEffect et Shape. La vue n'en fait pas partie.

La bonne approche consiste à animer les modifications


L'approche elle-même, lorsque nous demandons à View d'animer certains changements, était incorrecte. Pour View, vous ne pouvez pas utiliser la même approche que pour les formulaires. Au lieu de cela, l'animation doit être intégrée à chaque modificateur. La plupart des modificateurs intégrés prennent déjà en charge l'animation prête à l'emploi. Si vous souhaitez une animation pour vos propres modificateurs, vous pouvez utiliser le protocole AnimatableModifier au lieu de ViewModifier. Et là, vous pouvez implémenter la même chose que lors de l'animation des changements de forme, comme nous l'avons fait ci-dessus.

struct BorderPosition: AnimatableModifier {
    var position: CGFloat
    let startDate: Date = Date()
    public var animatableData: CGFloat {
        get {
            print("reading position: \(position) at time \(Date().timeIntervalSince(startDate))")
            return position
        }
        set {
            position = newValue
            print("setting position: \(position) at time \(Date().timeIntervalSince(startDate))")
        }
    }
    func body(content: Content) -> some View {
...
    }
    ...
}


Maintenant, tout va bien. Les messages dans la console aident à comprendre que notre animation fonctionne vraiment et que l'animation (nil) à l'intérieur du modificateur ne la gêne pas du tout. Mais voyons toujours comment cela fonctionne.

Tout d'abord, vous devez comprendre ce qu'est un modificateur.


Ici, nous avons une vue. Comme je l'ai dit dans la partie précédente, il s'agit d'une structure avec des paramètres stockés et des instructions d'assemblage. Cette instruction, dans l'ensemble, ne contient pas une séquence d'actions, qui est le code habituel que nous écrivons dans un style non déclaratif, mais une simple liste. Il répertorie l'autre vue, les modificateurs qui leur sont appliqués et les conteneurs dans lesquels ils sont inclus. Nous ne sommes pas encore intéressés par les conteneurs, mais parlons plus des modificateurs.

Un modificateur est à nouveau une structure avec des paramètres stockés et des instructions de traitement View. Il s'agit en fait de la même instruction que la vue - nous pouvons utiliser d'autres modificateurs, utiliser des conteneurs (par exemple, j'ai utilisé le GeometryReader un peu plus haut) et même une autre vue. Mais nous n'avons que du contenu entrant, et nous devons en quelque sorte le modifier en utilisant cette instruction. Les paramètres des modificateurs font partie de l'instruction. Mais le plus intéressant est qu'ils sont stockés.

Dans un article précédent, j'ai dit que l'instruction elle-même n'est pas stockée, qu'elle est lancée à chaque fois après la mise à jour de la vue. Tout est ainsi, mais il y a une nuance. À la suite du travail de cette instruction, nous n'avons pas tout à fait une image, comme je l'ai dit plus tôt - c'était une simplification. Les modificateurs ne disparaissent pas après l'exécution de cette instruction. Ils le restent tant que la vue parent existe.

Quelques analogies primitives


Comment décririons-nous un tableau dans un style déclaratif? Eh bien, nous énumérons 4 pieds et un comptoir. Ils les combineraient dans une sorte de conteneur, et avec l'aide de certains modificateurs, ils prescriraient comment ils sont attachés les uns aux autres. Par exemple, chaque pied indiquerait l'orientation par rapport au plan de travail et la position - quel pied est épinglé à quel coin. Oui, nous pouvons jeter les instructions après l'assemblage, mais les clous resteront dans le tableau. Les modificateurs aussi. A la sortie de la fonction body, nous n'avons pas tout à fait de tableau. En utilisant le corps, nous créons des éléments de table (vue) et des attaches (modificateurs), et nous mettons tout cela dans des tiroirs. La table elle-même est assemblée par un robot. Quelles fixations vous mettez dans une boîte à chaque jambe, vous obtiendrez une telle table.

La fonction .modifier (BorderPosition (position: position)), avec laquelle nous avons transformé la structure BorderPosition en modificateur, ne met qu'une vis supplémentaire dans le tiroir sur le pied de table. La structure BorderPosition est cette vis. Le rendu, au moment du rendu, prend cette case, en retire une jambe (Rectangle () dans notre cas), et obtient séquentiellement tous les modificateurs de la liste, avec les valeurs stockées en eux. La fonction corporelle de chaque modificateur est une instruction sur la façon de visser une jambe sur un dessus de table avec cette vis, et la structure elle-même avec des propriétés stockées, c'est cette vis.

Pourquoi est-il important de comprendre cela dans le contexte de l'animation? Parce que l'animation vous permet de modifier les paramètres d'un modificateur sans affecter les autres, puis de restituer l'image. Si vous faites de même en changeant certains@Stateparamètres - cela entraînera la réinitialisation de la vue imbriquée, des structures de modificateurs, etc., tout au long de la chaîne. Mais l'animation ne l'est pas.

En fait, lorsque nous changeons la valeur de la position lorsque nous appuyons sur un bouton, cela change. Jusqu'au bout. Aucun état intermédiaire n'est stocké dans la variable elle-même, ce qui ne peut pas être dit au sujet du modificateur. Pour chaque nouvelle image, les valeurs des paramètres du modificateur changent en fonction de la progression de l'animation en cours. Si l'animation dure 1 seconde, puis tous les 1/60 de seconde (l'iphone affiche exactement ce nombre d'images par seconde), la valeur animatableData à l'intérieur du modificateur changera, puis elle sera lue par le rendu pour le rendu, après quoi, après encore 1/60 de seconde, ce sera changé à nouveau, et relu par le rendu.

Ce qui est caractéristique, nous obtenons d'abord l'état final de la vue entière, souvenez-vous-en, et ce n'est qu'alors que le mécanisme d'animation commence à délimiter les valeurs de position interpolées dans le modificateur. L'état initial n'est stocké nulle part. Quelque part dans les entrailles de SwiftUI, seule la différence entre l'état initial et l'état final est stockée. Cette différence est à chaque fois multipliée par la fraction du temps écoulé. C'est ainsi que la valeur interpolée est calculée, qui est ensuite remplacée par animatableData.

Différence =

Acier
- Était Valeur actuelle = Acier - Différence * (1 - Temps écoulé) Temps écoulé = Temps depuis StartAnimations / DurationAnimations La

valeur actuelle doit être calculée autant de fois que le nombre d'images que nous devons afficher.

Pourquoi le «n'a pas été» utilisé explicitement? Le fait est que SwiftUI ne stocke pas l'état initial. Seule la différence est enregistrée: ainsi, en cas d'échec, vous pouvez simplement désactiver l'animation et revenir à l'état actuel de «devenir».

Cette approche vous permet de rendre l'animation réversible. Supposons, quelque part au milieu d'une animation, que l'utilisateur ait à nouveau appuyé sur un bouton et que nous ayons à nouveau modifié la valeur de la même variable. Dans ce cas, tout ce que nous devons faire pour réussir ce changement est d'utiliser "Current" comme valeur actuelle dans l'animation au moment du nouveau changement, de se souvenir de la nouvelle différence et de démarrer une nouvelle animation basée sur le nouveau "Became" et la nouvelle "Difference" . Oui, en fait, ces transitions d'une animation à une autre peuvent être un peu plus difficiles à simuler l'inertie, mais le sens, je pense, est compréhensible.

Ce qui est intéressant, c'est que l'animation de chaque image demande la valeur actuelle à l'intérieur du modificateur (à l'aide d'un getter). Ceci, comme vous pouvez le voir dans les enregistrements de service dans le journal, est responsable du statut de «Acier». Ensuite, en utilisant le setter, nous définissons le nouvel état qui est courant pour cette trame. Après cela, pour la trame suivante, la valeur actuelle du modificateur est à nouveau demandée - et elle est de nouveau «est devenue», c'est-à-dire Valeur finale vers laquelle l'animation se déplace. Il est probable que des copies des structures de modificateurs soient utilisées pour l'animation, et un getter d'une structure (un modificateur réel de la vue réelle) est utilisé pour obtenir la valeur "Acier", et un setter d'une autre (un modificateur temporaire utilisé pour l'animation) est utilisé. Je n'ai pas trouvé de moyen de m'en assurer, mais par des indications indirectes, tout ressemble à ça. En tous cas,les modifications au sein de l'animation n'affectent pas la valeur stockée de la structure des modificateurs de la vue actuelle. Si vous avez des idées sur la façon de savoir exactement ce qui se passe exactement avec le getter et le setter, écrivez à ce sujet dans les commentaires, je mettrai à jour l'article.

Plusieurs paramètres


Jusqu'à ce moment, nous n'avions qu'un seul paramètre pour l'animation. La question peut se poser, mais qu'en est-il si plusieurs paramètres sont passés au modificateur? Et si les deux doivent être animés en même temps? Voici comment avec le modificateur de cadre (largeur: hauteur :) par exemple. Après tout, nous pouvons changer simultanément la largeur et la hauteur de cette vue, et nous voulons que le changement se produise dans une animation, comment faire? Après tout, le paramètre AnimatableData en est un, que puis-je lui substituer?

Si vous regardez, Apple n'a qu'une seule exigence pour animatableData. Le type de données que vous y remplacez doit respecter le protocole VectorArithmetic. Ce protocole nécessite que l'objet garantisse les opérations arithmétiques minimales nécessaires pour pouvoir former un segment de deux valeurs et interpoler les points à l'intérieur de ce segment. Les opérations nécessaires pour cela sont l'addition, la soustraction et la multiplication. La difficulté est que nous devons effectuer ces opérations avec un seul objet qui stocke plusieurs paramètres. Ceux. nous devons emballer la liste complète de nos paramètres dans un conteneur qui sera un vecteur. Apple fournit un tel objet hors de la boîte, et nous propose d'utiliser une solution clé en main pour les cas pas très difficiles. Il s'appelle AnimatablePair.

Changeons un peu la tâche. Nous avons besoin d'un nouveau modificateur qui non seulement déplacera la barre verte, mais changera également sa hauteur. Ce seront deux paramètres modificateurs indépendants. Je ne donnerai pas le code complet de toutes les modifications à effectuer, vous pouvez le voir sur le github dans le fichier SimpleBorderMove. Je ne montrerai que le modificateur lui-même:

struct TwoParameterBorder: AnimatableModifier {
    var position: CGFloat
    var height: CGFloat
    let startDate: Date = Date()
    public var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get {
           print("animation read position: \(position), height: \(height)")
           return AnimatablePair(position, height)
        }
        set {
            self.position = newValue.first
            print("animating position at \(position)")
            self.height = newValue.second
            print("animating height at \(height)")
        }
    }
    init(position: CGFloat, height: CGFloat){
        self.position = position
        self.height = height
    }
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
                .animation(nil)
                .offset(x: -geometry.size.width / 2 + geometry.size.width * self.position, y: 0)
                .frame(height: self.height * (geometry.size.height - 20) + 20)
        }
    }
}


J'ai ajouté un autre curseur et un bouton pour modifier aléatoirement les deux paramètres à la fois dans la vue parent de SimpleView, mais il n'y a rien d'intéressant, donc pour le code complet, bienvenue dans le github.

Tout fonctionne, nous obtenons vraiment un changement cohérent dans la paire de paramètres emballés dans le tuple AnimatablePair. Pas mal.

Rien de confus dans cette mise en œuvre? Personnellement, je me suis tendu quand j'ai vu ce design:

        
self.position = newValue.first
self.height = newValue.second

Je n'ai indiqué nulle part lequel de ces paramètres devrait aller en premier et lequel en second. Comment SwiftUI décide quelle valeur mettre en premier et quelle valeur en deuxième? Eh bien, cela ne correspond-il pas aux noms des paramètres de fonction avec les noms des attributs de structure?

La première idée était l'ordre des attributs dans les paramètres de la fonction et leurs types, comme cela arrive avec @EnvironmentObject. Là, nous mettons simplement les valeurs dans la boîte, sans leur attribuer d'étiquette, puis nous les sortons de là, également sans indiquer d'étiquette. Là, tapez les sujets, et dans un seul type, l'ordre. Dans quel ordre ils mettent dans la boîte, de la même manière et l'obtiennent. J'ai essayé un ordre différent des arguments de la fonction, l'ordre des arguments pour initialiser la structure, l'ordre des attributs de la structure elle-même, me cognais généralement la tête contre le mur, mais ne pouvais pas confondre SwiftUI afin qu'il commence à animer la position avec des valeurs de hauteur et vice versa.

Puis cela m'est apparu. J'indique moi-même quel paramètre sera le premier et quel second dans le getter. SwiftUI n'a pas besoin de savoir exactement comment nous initialisons cette structure. Il peut obtenir la valeur animatableData avant le changement, l'obtenir après le changement, calculer la différence entre eux et renvoyer la même différence, mise à l'échelle proportionnellement à l'intervalle de temps écoulé, à notre configurateur. Il n'a généralement pas besoin de connaître la valeur elle-même dans AnimatableData. Et si vous ne confondez pas l'ordre des variables sur deux lignes adjacentes, alors tout sera en ordre, quelle que soit la complexité de la structure du reste du code.

Mais voyons ça. Après tout, nous pouvons créer notre propre vecteur conteneur (oh, j'adore, créer notre propre implémentation d'objets existants, vous l'avez peut-être remarqué dans un article précédent).



Nous décrivons la structure élémentaire, déclarons la prise en charge du protocole VectorArithmetic, ouvrons l'erreur sur le protocole non conforme, cliquons sur fix, et nous obtenons la déclaration de toutes les fonctions requises et des paramètres calculés. Il ne reste plus qu'à les remplir.

De la même manière, nous remplissons notre objet avec les méthodes requises pour le protocole AdditiveArithmetic (VectorArithmetic inclut son support).

struct MyAnimatableVector: VectorArithmetic{
    static func - (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position - rhs.position, height: lhs.height - rhs.height)
    }
    
    static func + (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position + rhs.position, height: lhs.height + rhs.height)
    }
    
    mutating func scale(by rhs: Double) {
        self.position = self.position * CGFloat(rhs)
        self.height = self.height * CGFloat(rhs)
    }
    
    var magnitudeSquared: Double{
         Double(self.position * self.position) + Double(self.height * self.height)
    }
    
    static var zero: MyAnimatableVector{
        MyAnimatableVector(position: 0, height: 0)
    }
    
    var position: CGFloat
    var height: CGFloat
}

  • Je pense pourquoi nous avons besoin de + et - évidemment.
  • l'échelle est une fonction de la mise à l'échelle. Nous prenons la différence «C'était - c'est devenu» et la multiplions par le stade actuel de l'animation (de 0 à 1). "C'est devenu + Différence * (1 - Étape)" et il y aura une valeur actuelle que nous devrions supprimer dans animatableData
  • Zéro est probablement nécessaire pour initialiser de nouveaux objets dont les valeurs seront utilisées pour l'animation. L'animation utilise .zero au tout début, mais je n'ai pas pu comprendre exactement comment. Cependant, je ne pense pas que ce soit important.
  • magnitudeSquared est un produit scalaire d'un vecteur donné avec lui-même. Pour un espace à deux dimensions, cela signifie la longueur du vecteur au carré. Ceci est probablement utilisé pour pouvoir comparer deux objets l'un avec l'autre, non pas par élément, mais dans son ensemble. Il ne semble pas être utilisé à des fins d'animation.

De manière générale, les fonctions «- =» «+ =» sont également incluses dans le support du protocole, mais pour la structure elles peuvent être générées automatiquement sous cette forme

    static func -= (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs - rhs
    }

    static func += (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs + rhs
    }


Pour plus de clarté, j'ai exposé toute cette logique sous forme de schéma. L'image est cliquable. Ce que nous obtenons pendant l'animation est surligné en rouge - à chaque tick (1/60 seconde), le temporisateur donne une nouvelle valeur de t, et nous, dans le régleur de notre modificateur, obtenons une nouvelle valeur de animatableData. Voilà comment l'animation fonctionne sous le capot. Dans le même temps, il est important de comprendre qu'un modificateur est une structure stockée et qu'une copie du modificateur actuel avec un nouvel état actuel est utilisée pour afficher l'animation.





Pourquoi AnimatableData ne peut être qu'une structure


Il y a encore un point. Vous ne pouvez pas utiliser des classes en tant qu'objet AnimatableData. Formellement, vous pouvez décrire pour une classe toutes les méthodes nécessaires du protocole correspondant, mais cela ne décollera pas, et voici pourquoi. Comme vous le savez, une classe est un type de données de référence et une structure est un type de données basé sur des valeurs. Lorsque vous créez une variable basée sur une autre, dans le cas d'une classe, vous copiez un lien vers cet objet et dans le cas d'une structure, vous créez un nouvel objet basé sur les valeurs de l'existant. Voici un petit exemple illustrant cette différence:

    struct TestStruct{
        var value: CGFloat
        mutating func scaled(by: CGFloat){
            self.value = self.value * by
        }
    }
    class TestClass{
        var value: CGFloat
        func scaled(by: CGFloat){
             self.value = self.value * by
        }
        init(value: CGFloat){
            self.value = value
        }
    }
        var stA = TestStruct(value: 5)
        var stB = stA
        stB.scaled(by: 2)
        print("structs: a = \(stA.value), b = \(stB.value))") //structs: a = 5.0, b = 10.0)
        var clA = TestClass(value: 5)
        var clB = clA
        clB.scaled(by: 2)
        print("classes: a = \(clA.value), b = \(clB.value))") //classes: a = 10.0, b = 10.0)

Avec l'animation, exactement la même chose se produit. Nous avons un objet AnimatableData représentant la différence entre «était» et «est devenu». Nous devons calculer une partie de cette différence pour la refléter sur l'écran. Pour ce faire, nous devons copier cette différence et la multiplier par un nombre représentant l'étape actuelle de l'animation. Dans le cas de la structure, cela n'affectera pas la différence elle-même, mais dans le cas de la classe, ce sera le cas. Le premier cadre que nous dessinons est l'état «était». Pour ce faire, nous devons calculer Acier + Différence * Étape actuelle - Différence. Dans le cas de la classe, dans la première image, nous multiplions la différence par 0, en la mettant à zéro, et toutes les images suivantes sont dessinées de sorte que la différence = 0. l'animation semble être correctement dessinée, mais en fait, nous voyons une transition instantanée d'un état à un autre, comme s'il n'y avait pas d'animation.

Vous pouvez probablement écrire une sorte de code de bas niveau qui crée de nouvelles adresses mémoire pour le résultat de la multiplication - mais pourquoi? Vous pouvez simplement utiliser des structures - elles sont créées pour cela.

Pour ceux qui veulent comprendre exactement comment SwiftUI calcule les valeurs intermédiaires, par quelles opérations et à quel moment, les messages sont poussés dans la console du projet . De plus, j'y ai inséré sleep 0.1 seconde pour simuler des calculs gourmands en ressources à l'intérieur de l'animation, amusez-vous :)

Animation d'écran: .transition ()


Jusqu'à ce point, nous avons parlé d'animer une modification d'une valeur passée à un modificateur ou à un formulaire. Ce sont des outils assez puissants. Mais il existe un autre outil qui utilise également l'animation - c'est l'animation de l'apparition et de la disparition de la vue.

Dans le dernier article, nous avons parlé du fait que dans le style déclaratif de if-else, ce n'est pas du tout le contrôle du flux de code lors de l'exécution, mais plutôt une vue de Schrödinger. Il s'agit d'un conteneur contenant deux vues en même temps, qui décide lequel afficher en fonction d'une certaine condition. Si vous manquez le bloc else, EmptyView s'affiche à la place de la deuxième vue.

La commutation entre les deux vues peut également être animée. Pour ce faire, utilisez le modificateur .transition ().

struct TransitionView: View {
    let views: [AnyView] = [AnyView(CustomCircleTestView()), AnyView(SimpleBorderMove())]
    @State var currentViewInd = 0
    var body: some View {
        VStack{
            Spacer()
            ZStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                        }
                    }
                }
            }
            HStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    RoundedRectangle(cornerRadius: 10)
                        .fill(ind == self.currentViewInd ? Color.green : Color.gray)
                        .overlay(
                            Text("\(ind + Int(1))"))
                        .onTapGesture{
                            withAnimation{
                                self.currentViewInd = ind
                            }
                    }
                }
            }
                .frame(height: 50)
            Spacer()
        }
    }
}

Voyons comment cela fonctionne. Tout d'abord, à l'avance, même au stade de l'initialisation de la vue parent, nous avons créé et placé plusieurs vues dans le tableau. Le tableau est de type AnyView, car les éléments du tableau doivent avoir le même type, sinon ils ne peuvent pas être utilisés dans ForEach. Type de résultat opaque de l' article précédent , vous vous souvenez?

Ensuite, nous avons prescrit l'énumération des indices de ce tableau, et pour chacun d'eux, nous affichons la vue par cet index. Nous sommes obligés de le faire et de ne pas itérer sur View immédiatement, car pour travailler avec ForEach, nous devons attribuer un identifiant interne à chaque élément afin que SwiftUI puisse parcourir le contenu de la collection. Comme alternative, nous devrions créer un identifiant de proxy dans chaque vue, mais pourquoi, si des index peuvent être utilisés?

Nous emballons chaque vue de la collection dans un état et nous la montrons uniquement si elle est active. Cependant, la construction if-else ne peut tout simplement pas exister ici, le compilateur la prend pour le contrôle de flux, nous mettons donc tout cela dans le groupe afin que le compilateur comprenne exactement ce que c'est View, ou plus précisément, les instructions pour que ViewBuilder crée un conteneur ConditionalContent facultatif <View1, View2>.

Maintenant, lorsque vous modifiez la valeur de currentViewInd, SwiftUI masque la vue active précédente et affiche la vue actuelle. Comment aimez-vous cette navigation dans l'application?


Tout ce qui reste à faire est de mettre la modification currentViewInd dans l'encapsuleur withAnimation, et le basculement entre les fenêtres deviendra fluide.

Ajoutez le modificateur .transition, en spécifiant .scale comme paramètre. Cela rendra l'animation de l'apparence et de la disparition de chacune de ces vues différente - en utilisant la mise à l'échelle plutôt que la transparence utilisée par défaut SwiftUI.

                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                                .transition(.scale)
                        }
                    }
                }


Notez que la vue apparaît et disparaît avec la même animation, seule la disparition défile dans l'ordre inverse. En fait, nous pouvons attribuer individuellement des animations à la fois pour l'apparition et la disparition d'une vue. Une transition asymétrique est utilisée pour cela.

                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                               .transition(.asymmetric(
                                    insertion: insertion: AnyTransition.scale(scale: 0.1, anchor: .leading).combined(with: .opacity),
                                    removal: .move(edge: .trailing)))
                        }
                    }


La même animation .scale est utilisée pour apparaître à l'écran, mais maintenant nous avons spécifié les paramètres pour son utilisation. Il ne commence pas par une taille zéro (point), mais par une taille de 0,1 par rapport à la taille habituelle. Et la position de départ de la petite fenêtre n'est pas au centre de l'écran, mais décalée vers le bord gauche. De plus, pas une transition n'est responsable de l'apparence, mais deux. Ils peuvent être combinés avec .combined (avec :). Dans ce cas, nous avons ajouté de la transparence.

La disparition de la vue est maintenant rendue par une autre animation - balayant le bord droit de l'écran. J'ai rendu l'animation un peu plus lente pour que vous puissiez y jeter un œil.

Et comme toujours, j'ai hâte d'écrire ma propre version d'animation de transit. C'est encore plus simple que les formulaires animés ou les modificateurs.

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            .clipped()
    }
}

extension AnyTransition {
    static func spinIn(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: -90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
    static func spinOut(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: 90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
}


Pour commencer, nous écrivons le modificateur habituel dans lequel nous transférons un certain nombre - l'angle de rotation en degrés, ainsi que le point par rapport auquel cette rotation se produit. Ensuite, nous étendons le type AnyTransition avec deux fonctions. Cela aurait pu en être un, mais cela me semblait plus pratique. J'ai trouvé plus facile d'attribuer des noms parlants à chacun d'eux que de contrôler les degrés de rotation directement dans la vue elle-même.

Le type AnyTransition a une méthode de modificateur statique, dans laquelle nous transmettons deux modificateurs, et nous obtenons un objet AnyTransition qui décrit une transition en douceur d'un état à un autre. l'identité est le modificateur d'état normal de la vue animée. Actif est l'état du début de l'animation pour l'apparence de la vue, ou la fin de l'animation pour la disparition, c'est-à-dire l'autre extrémité du segment, les états dans lesquels seront interpolés.

Donc, spinIn implique que je vais l'utiliser pour faire apparaître la vue de l'extérieur de l'écran (ou de l'espace alloué à la vue) en tournant dans le sens horaire autour du point spécifié. spinOut signifie que la vue disparaîtra de la même manière, en tournant autour du même point, également dans le sens des aiguilles d'une montre.

Selon mon idée, si vous utilisez le même point pour l'apparition et la disparition de la vue, vous obtenez l'effet de tourner tout l'écran autour de ce point.

Toute l'animation est basée sur la mécanique des modificateurs standard. Si vous écrivez un modificateur entièrement personnalisé, vous devez implémenter les exigences du protocole AnimatableModifier, comme nous l'avons fait avec TwoParameterBorder, ou utiliser les modificateurs intégrés à l'intérieur qui fournissent leur propre animation par défaut. Dans ce cas, je me suis appuyé sur l'animation .rotationEffect () intégrée dans mon modificateur SpinTransitionModifier.

Le modificateur .transition () précise uniquement ce qu'il faut considérer comme point de départ et d'arrivée de l'animation. Si nous devons demander l'état AnimatableData avant de démarrer l'animation, pour demander le modificateur AnimatableData de l'état actuel, calculez la différence, puis animez la diminution de 1 à 0, puis .transition () modifie simplement les données d'origine. Vous n'êtes pas attaché à l'état de votre vue, vous n'êtes pas basé sur celui-ci. Vous spécifiez explicitement l'état initial et final vous-même, à partir d'eux vous obtenez AnimatableData, calculez la différence et l'animez. Ensuite, à la fin de l'animation, votre vue actuelle apparaît au premier plan.

Soit dit en passant, l'identité est un modificateur qui restera appliqué à votre vue à la fin de l'animation. Sinon, une erreur entraînerait des sauts à la fin de l'animation d'apparition et le début de l'animation de disparition. Ainsi, la transition peut être considérée comme «deux en un» - en appliquant un modificateur spécifique directement à la vue + la possibilité d'animer ses changements lorsque la vue apparaît et disparaît.

Honnêtement, ce mécanisme de contrôle d'animation me semble très fort, et je suis un peu désolé de ne pouvoir l'utiliser pour aucune animation. Je ne refuserais pas une telle création d'animation fermée sans fin. Cependant, nous en parlerons dans le prochain article.

Pour mieux voir comment le changement se produit, j'ai remplacé notre vue de test par des carrés élémentaires, signés avec des nombres et encadrés.

                    Group{
                        if ind == self.currentViewInd{
                            //self.views[ind]
                            Rectangle()
                                .fill(Color.gray)
                                .frame(width: 100, height: 100)
                                .border(Color.black, width: 2)
                                .overlay(Text("\(ind + 1)"))
                              .transition(.asymmetric(
                                  insertion: .spinIn(anchor: .bottomTrailing),
                                  removal: .spinOut(anchor: .bottomTrailing)))
                        }
                    }


Et pour rendre ce mouvement encore meilleur, j'ai supprimé .clipped () du modificateur SpinTransitionModifier:

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            //.clipped()
    }
}


Soit dit en passant, nous avons maintenant besoin de SpinTransitionModifier dans notre propre modificateur. Il a été créé uniquement afin de combiner les deux modificateurs, rotationEffect et clipped () en un seul, afin que l'animation de rotation ne dépasse pas la portée sélectionnée pour notre vue. Maintenant, nous pouvons utiliser .rotationEffect () directement à l'intérieur de .modifier (), nous n'avons pas besoin d'un intermédiaire sous la forme de SpinTransitionModifier.

Quand la vue meurt


Un point intéressant est le cycle de vie de la vue s'il est placé dans un if-else. La vue, bien qu'initiée et enregistrée en tant qu'élément de tableau, n'est pas stockée en mémoire. Tout sonEtatles paramètres sont réinitialisés par défaut la prochaine fois qu'ils apparaissent à l'écran. C'est presque la même chose que l'initialisation. Malgré le fait que la structure objet elle-même existe toujours, le rendu l'a retirée de son champ de vision, car elle ne l'est pas. D'une part, cela réduit l'utilisation de la mémoire. Si vous avez un grand nombre de vues complexes dans le tableau, le rendu devra les dessiner toutes en permanence, réagissant aux changements - cela affecte négativement les performances. Si je ne me trompe pas, c'était le cas avant la mise à jour Xcode 11.3. Désormais, les vues inactives sont déchargées de la mémoire de rendu.

D'un autre côté, nous devons déplacer tout état important au-delà de la portée de ce point de vue. Pour cela, il est préférable d'utiliser les variables @EnvironmentObject.

Revenant au cycle de vie, il convient également de noter que le modificateur .onAppear {}, s'il est enregistré dans cette vue, fonctionne immédiatement après avoir modifié la condition et l'apparence de la vue à l'écran, avant même le début de l'animation. En conséquence, onDisappear {} est déclenché après la fin de l'animation de disparition. Gardez cela à l'esprit si vous prévoyez de les utiliser avec une animation de transition.

Et après?


phew Cela s'est avéré assez volumineux, mais en détail et, je l'espère, intelligible. Honnêtement, j'espérais parler de l'animation arc-en-ciel dans le cadre d'un article, mais je ne pouvais pas m'arrêter à temps avec les détails. Alors attendez la suite.

La partie suivante nous attend:

  • utilisation des dégradés: linéaire, circulaire et angulaire - tout sera utile
  • La couleur n'est pas du tout la couleur: choisissez judicieusement.
  • animation en boucle: comment démarrer et comment arrêter, et comment arrêter immédiatement (sans animation, changer l'animation - oui, il y en a une aussi)
  • animation actuelle du flux: priorités, remplacements, animation différente pour différents objets
  • détails sur les timings d'animation: nous allons conduire des timings à la fois dans la queue et dans la crinière, jusqu'à notre propre implémentation de timingCurve (oh, gardez-moi sept :))
  • comment trouver le moment actuel de l'animation jouée
  • Si SwiftUI ne suffit pas

Je vais parler de tout cela en détail en utilisant l'exemple de la création d'une animation arc-en-ciel, comme dans l'image:


Je ne suis pas allé par la voie facile, mais j'ai rassemblé tous les râteaux que j'ai pu atteindre, incarnant cette animation selon les principes décrits ci-dessus. L'histoire à ce sujet devrait s'avérer très informative et riche en astuces et en toutes sortes de hacks, sur lesquels il y avait peu de rapports, et qui sera utile à ceux qui décident de devenir un pionnier de SwiftUI. Il apparaîtra environ dans une semaine ou deux. Au fait, vous pouvez vous abonner pour ne rien manquer. Mais cela, bien sûr, uniquement si le matériel vous semble utile et que la méthode de présentation est approuvée. Ensuite, votre abonnement aidera à mettre rapidement de nouveaux articles au sommet, en les amenant tôt à un public plus large. Sinon, écrivez dans les commentaires ce qui ne va pas.

All Articles