SwiftUI en los estantes: Animación. Parte 1

imagen

Recientemente me encontré con un nuevo artículo en el que los chicos intentaron reproducir un concepto interesante usando SwiftUI.

imagen

Esto es lo que hicieron: estudié su código con interés, pero experimenté cierta frustración. No, no en el sentido de que hicieron algo mal, para nada. Simplemente no aprendí de su código prácticamente nada nuevo. Su implementación es más sobre Combinar que sobre animación. Y decidí construir mi lunopark para escribir mi artículo sobre animación en SwiftUI, implementando aproximadamente el mismo concepto, pero usando el 100% de las capacidades de la animación incorporada, incluso si no es muy efectivo. Para estudiar, hasta el final. Para experimentar, así que con un brillo :)

Esto es lo que obtuve:


Sin embargo, para una divulgación completa del tema, tuve que hablar con algunos detalles sobre los conceptos básicos. El texto resultó ser voluminoso y, por lo tanto, lo dividí en dos artículos. Aquí está la primera parte, más bien, un tutorial sobre animación en general, no directamente relacionado con la animación del arco iris, que analizaré en detalle en el próximo artículo.

En este artículo, hablaré sobre los conceptos básicos, sin los cuales puede confundirse fácilmente en ejemplos más complejos. Gran parte de lo que hablaré, de una forma u otra, ya se ha descrito en artículos en inglés como esta serie ( 1 , 2 , 3 , 4) Yo, por otro lado, no me concentré tanto en enumerar las formas de trabajar como en describir cómo funciona exactamente esto. Y como siempre, experimenté mucho, así que me apresuré a compartir los resultados más interesantes.

advertencia: debajo del gato hay muchas imágenes y animaciones gif.

TLDR


El proyecto está disponible en github . Puede ver el resultado actual con la animación del arco iris en TransitionRainbowView (), pero no me apresuraría en su lugar, pero esperé el siguiente artículo. Además, al prepararlo, peino un poco el código.

En este artículo, discutiremos solo los conceptos básicos y afectaremos solo los contenidos de la carpeta Bases.

Introducción


Lo admito, no iba a escribir este artículo ahora. Tenía un plan según el cual se suponía que un artículo sobre animación era el tercero o incluso el cuarto consecutivo. Sin embargo, no pude resistirme, realmente quería proporcionar un punto de vista alternativo.

Quiero hacer una reservación de inmediato. No creo que se hayan cometido errores en el artículo mencionado, o el enfoque utilizado es incorrecto. De ningún modo. Construye un modelo de objeto del proceso (animación) que, respondiendo a la señal recibida, comienza a hacer algo. Sin embargo, en cuanto a mí, este artículo probablemente revela el trabajo con el marco Combine. Sí, este marco es una parte importante de SwiftUI, pero se trata más de estilo de reacción en general que de animación.

Mi opción ciertamente no es más elegante, más rápida y fácil de mantener. Sin embargo, revela mucho mejor lo que está bajo el capó de SwiftUI, y de hecho este fue el propósito del artículo: descubrirlo primero.

Como dije en un artículo anteriorpor SwiftUI, comencé mi inmersión en el mundo del desarrollo móvil de inmediato con SwiftUI, ignorando UIKit. Esto, por supuesto, tiene un precio, pero hay ventajas. No estoy tratando de vivir en un nuevo monasterio de acuerdo con la antigua carta. Honestamente, todavía no conozco ninguna carta, así que no tengo un rechazo a lo nuevo. Es por eso que este artículo, me parece, puede ser valioso no solo para principiantes, como yo, sino también para aquellos que estudian SwiftUI que ya tienen antecedentes en forma de desarrollo en UIKit. Me parece que muchas personas carecen de un aspecto fresco. No haga lo mismo, intente adaptar una nueva herramienta a los dibujos antiguos, pero cambie su visión de acuerdo con las nuevas posibilidades.

Nosotros 1c-nicks pasamos por esto con "formas controladas". Este es un tipo de SwiftUI en el mundo de 1s, que sucedió hace más de 10 años. De hecho, la analogía es bastante precisa, porque los formularios administrados son solo una nueva forma de dibujar una interfaz. Sin embargo, cambió por completo la interacción cliente-servidor de la aplicación en su conjunto, y la imagen del mundo en la mente de los desarrolladores en particular. Esto no fue fácil, yo mismo no quise estudiarlo durante unos 5 años, porque Pensé que muchas de las oportunidades que se cortaban allí eran simplemente necesarias para mí. Pero, como lo ha demostrado la práctica, la codificación en formularios administrados no solo es posible, sino también necesaria.

Sin embargo, no hablemos más de eso. Obtuve una guía detallada e independiente que no tiene referencias u otros enlaces con el artículo mencionado o el 1er pasado. Paso a paso, profundizaremos en los detalles, características, principios y limitaciones. Vamos.

Forma animada


Cómo funciona la animación en general


Entonces, la idea principal de la animación es la transformación de un cambio particular y discreto en un proceso continuo. Por ejemplo, el radio del círculo era de 100 unidades, se convirtió en 50 unidades. Sin animación, el cambio ocurrirá instantáneamente, con animación, sin problemas. ¿Cómo funciona? Muy simple. Para cambios suaves, necesitamos interpolar varios valores dentro del segmento "Fue ... se ha convertido". En el caso del radio, tendremos que dibujar varios círculos intermedios con un radio de 98 unidades, 95 unidades, 90 unidades ... 53 unidades y, finalmente, 50 unidades. SwiftUI puede hacer esto de manera fácil y natural, simplemente envuelva el código que realiza este cambio conAnimation {...}. Parece mágico ... Hasta que quieras implementar algo un poco más complicado que "hola mundo".

Pasemos a los ejemplos. El objeto más simple y más comprensible para la animación se considera animación de formas. La forma (todavía llamaré a la estructura conforme al protocolo de forma de forma) en SwiftUI es una estructura con parámetros que pueden ajustarse a estos límites. Aquellos. Es una estructura que tiene la función cuerpo (en rect: CGRect) -> Path. Todo lo que necesita el tiempo de ejecución para dibujar este formulario es solicitar su esquema (el resultado de la función es un objeto de tipo Path, de hecho, es una curva Bezier) para el tamaño requerido (especificado como un parámetro de función, un rectángulo de tipo CGRect).

La forma es una estructura almacenada. Al inicializarlo, almacena en los parámetros todo lo que necesita para dibujar su contorno. El tamaño de la selección para este formulario puede cambiar, entonces todo lo que se necesita es obtener un nuevo valor de Ruta para el nuevo marco CGRect, y listo.

Comencemos a codificar ya:

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")
            }
        }
    }
}


Entonces, tenemos un círculo (Circle ()), cuyo radio podemos cambiar usando el control deslizante. Esto sucede sin problemas, ya que el control deslizante nos da todos los valores intermedios. Sin embargo, cuando hace clic en el botón "establecer radio predeterminado", el cambio tampoco ocurre instantáneamente, sino de acuerdo con la instrucción withAnimation (.linear (duración: 1)). Linealmente, sin aceleración, estirado durante 1 segundo. ¡Clase! ¡Dominamos la animación! No estamos de acuerdo :)

Pero, ¿qué pasa si queremos implementar nuestra propia forma y animar sus cambios? ¿Es difícil hacer esto? Vamos a revisar.

Hice una copia de Circle de la siguiente manera:

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()
        }
    }
}

El radio del círculo se calcula como la mitad del ancho y la altura más pequeños del borde del área de la pantalla que se nos asignó. Si el ancho es mayor que la altura, comenzamos desde el medio del borde superior (Nota 1), describimos el círculo completo en el sentido de las agujas del reloj (Nota 2) y cerramos nuestro esquema al respecto. Si la altura es mayor que el ancho, comenzamos desde la mitad del borde derecho, también describimos el círculo completo en sentido horario y cerramos el contorno.

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

Nota 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.

Veamos cómo responderá nuestro nuevo círculo a los cambios en el bloque 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")
            }
        }
    }
}


¡Guauu! ¡Aprendimos a hacer nuestras propias imágenes de forma libre y animarlas! ¿Es tan?

Realmente no. Todo el trabajo aquí lo realiza el modificador .frame (ancho: self.radius * 2, height: self.radius * 2). Dentro del bloque withAnimation {...} cambiamosEstadouna variable, envía una señal para reinicializar CustomCircleView () con un nuevo valor de radio, este nuevo valor cae en el modificador .frame (), y este modificador ya puede animar cambios de parámetros. Nuestro formulario CustomCircle () reacciona a esto con animación, porque no depende de otra cosa que no sea el tamaño del área seleccionada para ello. El cambio del área ocurre con la animación (es decir, gradualmente, la interpolación de los valores intermedios entre ella se ha convertido), por lo tanto, nuestro círculo se dibuja con la misma animación.

Simplifiquemos (¿o aún complicamos?) Nuestra forma un poco. No calcularemos el radio en función del tamaño del área disponible, pero transferiremos el radio en la forma finalizada, es decir. conviértalo en un parámetro de estructura almacenada.

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)
...
    }
}


Bueno, la magia se pierde irremediablemente.

Excluimos el modificador frame () de nuestro CustomCircleView (), cambiando la responsabilidad del tamaño del círculo a la forma misma, y ​​la animación desapareció. Pero no importa; enseñar una forma de animar cambios en sus parámetros no es demasiado difícil. Para hacer esto, debe implementar los requisitos del protocolo 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 magia está de vuelta otra vez!

Y ahora podemos decir con confianza que nuestra forma está realmente animada: puede reflejar cambios en sus parámetros con animación. Le dimos al sistema una ventana donde puede llenar los valores interpolados necesarios para la animación. Si existe tal ventana, los cambios son animados. Si no es así, los cambios tienen lugar sin animación, es decir. instantáneamente. Nada complicado, ¿verdad?

AnimatableModifier


Cómo animar cambios dentro de una vista


Pero vayamos directamente a Ver. Supongamos que queremos animar la posición de un elemento dentro de un contenedor. En nuestro caso, será un simple rectángulo de color verde y un ancho de 10 unidades. Animaremos su posición horizontal.

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
    }
}


¡Clase! ¡Trabajos! ¡Ahora sabemos todo sobre animación!

Realmente no. Si nos fijamos en la consola, veremos lo siguiente:
Posición de
cálculo de init de BorderView : 0.4595176577568054
Posición de
cálculo de init de BorderView: 0.468130886554718
Posición de
cálculo de init de BorderView : 0.0

En primer lugar, cada cambio en el valor de posición utilizando el control deslizante hace que BorderView se reinicialice con el nuevo valor. Es por eso que vemos un movimiento suave de la línea verde después del control deslizante, el control deslizante simplemente informa muy a menudo un cambio en la variable y parece una animación, pero no lo es. Usar el control deslizante es realmente conveniente cuando depura la animación. Puede usarlo para rastrear algunos estados de transición.

En segundo lugar, vemos que la posición de cálculo simplemente se volvió igual a 0, y no hubo registros intermedios, como fue el caso con la animación correcta del círculo. ¿Por qué?

La cosa, como en el ejemplo anterior, está en el modificador. Esta vez, el modificador .offset () obtiene el nuevo valor de sangría y anima el cambio en sí. Aquellos. de hecho, no es el cambio en el parámetro de posición que pretendíamos animar, sino el cambio horizontal de la sangría en el modificador .offset () derivado de él. En este caso, este es un reemplazo inofensivo, el resultado es el mismo. Pero como han venido, profundicemos. Hagamos nuestro propio modificador, que recibirá la posición (de 0 a 1) en la entrada, recibirá el tamaño del área disponible y calculará la sangría.

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))
    }
}

En el BorderView original, respectivamente, ya no se necesita GeometryReader, así como la función para calcular la sangría:

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


Sí, todavía usamos el modificador .offset () dentro de nuestro modificador, pero luego agregamos el modificador .animation (nil), que bloquea nuestra propia animación de desplazamiento. Entiendo que en esta etapa puede decidir que es suficiente para eliminar este bloqueo, pero no llegaremos al fondo de la verdad. Y la verdad es que nuestro truco con animatableData para BorderView no funciona. De hecho, si mira la documentación del protocolo Animatable , notará que la implementación de este protocolo solo es compatible con AnimatableModifier, GeometryEffect y Shape. La vista no está entre ellos.

El enfoque correcto es animar modificaciones


El enfoque en sí mismo, cuando le pedimos a View que anime algunos cambios, era incorrecto. Para Ver, no puede usar el mismo enfoque que para los formularios. En cambio, la animación debe integrarse en cada modificador. La mayoría de los modificadores incorporados ya admiten animación de fábrica. Si desea animación para sus propios modificadores, puede usar el protocolo AnimatableModifier en lugar de ViewModifier. Y allí puede implementar lo mismo que al animar los cambios de forma, como lo hicimos anteriormente.

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 {
...
    }
    ...
}


Ahora todo está bien. Los mensajes en la consola ayudan a comprender que nuestra animación realmente funciona, y .animation (nil) dentro del modificador no interfiere en absoluto. Pero sigamos descubriendo exactamente cómo funciona.

Primero, necesitas entender qué es un modificador.


Aquí tenemos una vista. Como dije en la parte anterior, esta es una estructura con parámetros almacenados e instrucciones de ensamblaje. Esta instrucción, en general, no contiene una secuencia de acciones, que es el código habitual que escribimos en un estilo no declarativo, sino una lista simple. Enumera la otra Vista, los modificadores que se les aplicaron y los contenedores en los que están incluidos. Todavía no estamos interesados ​​en los contenedores, pero hablemos más sobre los modificadores.

Un modificador es nuevamente una estructura con parámetros almacenados y Ver instrucciones de procesamiento. Esta es en realidad la misma instrucción que la Vista: podemos usar otros modificadores, usar contenedores (por ejemplo, usé el GeometryReader un poco más alto) e incluso otra Vista. Pero solo tenemos contenido entrante, y necesitamos cambiarlo de alguna manera usando esta instrucción. Los parámetros modificadores son parte de la instrucción. Pero lo más interesante es que están almacenados.

En un artículo anterior, dije que la instrucción en sí no se almacena, que se lanza cada vez que se actualiza la Vista. Todo es así, pero hay un matiz. Como resultado del trabajo de esta instrucción, no obtuvimos una imagen clara, como dije antes: fue una simplificación. Los modificadores no desaparecen después de la operación de esta instrucción. Permanecen así mientras exista la Vista principal.

Algunas analogías primitivas


¿Cómo describiríamos una tabla en un estilo declarativo? Bueno, enumeraríamos 4 patas y una encimera. Los combinarían en algún tipo de contenedor, y con la ayuda de algunos modificadores prescribirían cómo se unen entre sí. Por ejemplo, cada pata indicaría la orientación con respecto a la encimera y la posición: qué pata está fijada en qué esquina. Sí, podemos tirar las instrucciones después del montaje, pero las uñas permanecerán en la mesa. Así son los modificadores. A la salida de la función del cuerpo, no tenemos una tabla. Usando cuerpo, creamos elementos de tabla (vista) y sujetadores (modificadores), y lo colocamos todo en cajones. La mesa en sí está ensamblada por un robot. Con qué sujetadores colocas en una caja en cada pata, obtendrás dicha mesa.

La función .modifier (BorderPosition (posición: posición)), con la que convertimos la estructura BorderPosition en un modificador, solo pone un tornillo adicional en el cajón a la pata de la mesa. La estructura de BorderPosition es este tornillo. El renderizado, en el momento del renderizado, toma este cuadro, le quita un tramo (Rectángulo () en nuestro caso) y obtiene secuencialmente todos los modificadores de la lista, con los valores almacenados en ellos. La función del cuerpo de cada modificador es una instrucción sobre cómo atornillar una pata a una mesa con este tornillo, y la estructura misma con propiedades almacenadas, este es ese tornillo.

¿Por qué es importante entender esto en el contexto de la animación? Debido a que la animación le permite cambiar los parámetros de un modificador sin afectar a los otros, y luego volver a renderizar la imagen. Si haces lo mismo cambiando algunos@Stateparámetros: esto provocará la reinicialización de la Vista anidada, las estructuras modificadoras, etc., a lo largo de toda la cadena. Pero la animación no lo es.

De hecho, cuando cambiamos el valor de la posición cuando presionamos un botón, cambia. Hasta el final. No se almacenan estados intermedios en la variable en sí, lo que no se puede decir sobre el modificador. Para cada nuevo cuadro, los valores de los parámetros modificadores cambian de acuerdo con el progreso de la animación actual. Si la animación dura 1 segundo, entonces cada 1/60 de segundo (el iPhone muestra exactamente esa cantidad de cuadros por segundo), el valor de animatableData dentro del modificador cambiará, luego será leído por el render para renderizar, después de lo cual, después de otro 1/60 de segundo, será cambiado nuevamente, y leído nuevamente por el render.

Lo que es característico, primero obtenemos el estado final de toda la Vista, lo recordamos y solo entonces el mecanismo de animación comienza a aplicar los valores de posición interpolados al modificador. El estado inicial no se almacena en ningún lado. En algún lugar de las entrañas de SwiftUI, solo se almacena la diferencia entre el estado inicial y el final. Esta diferencia se multiplica cada vez por la fracción del tiempo transcurrido. Así es como se calcula el valor interpolado, que posteriormente se sustituye en animatableData.

Diferencia =

Acero
- Era valor actual = Acero - Diferencia * (1 - Tiempo transcurrido) Tiempo transcurrido = Tiempo desde inicioAnimaciones / DuraciónAnimaciones El

valor actual debe calcularse tantas veces como el número de cuadros que necesitamos mostrar.

¿Por qué el "Was" no se usa explícitamente? El hecho es que SwiftUI no almacena el estado inicial. Solo se almacena la diferencia: por lo tanto, en el caso de algún tipo de falla, simplemente puede apagar la animación y pasar al estado actual de "convertirse".

Este enfoque le permite hacer que la animación sea reversible. Supongamos que, en algún lugar en el medio de una animación, el usuario presionó un botón nuevamente, y nuevamente cambiamos el valor de la misma variable. En este caso, todo lo que tenemos que hacer para vencer este cambio maravillosamente es tomar "Actual" dentro de la animación en el momento del nuevo cambio como "It", recordar la nueva Diferencia y comenzar una nueva animación basada en el nuevo "Became" y la nueva "Diferencia" . Sí, de hecho, estas transiciones de una animación a otra pueden ser un poco más difíciles de simular inercia, pero creo que el significado es comprensible.

Lo interesante es que la animación de cada cuadro solicita el valor actual dentro del modificador (usando un captador). Esto, como puede ver en los registros de servicio en el registro, es responsable del estado de "Acero". Luego, usando el setter, establecemos el nuevo estado actual para este marco. Después de eso, para el siguiente cuadro, se solicita nuevamente el valor actual del modificador, y nuevamente "se ha convertido", es decir El valor final al que se mueve la animación. Es probable que se usen copias de las estructuras modificadoras para la animación, y se use un captador de una estructura (un modificador real de la Vista real) para obtener el valor "Acero", y se use un definidor de otra (un modificador temporal usado para la animación). No he encontrado una manera de asegurarme de esto, pero por indicaciones indirectas todo se ve así. De todas formas,Los cambios dentro de la animación no afectan el valor almacenado de la estructura modificadora de la Vista actual. Si tiene ideas sobre cómo averiguar exactamente qué sucede exactamente con el getter y el setter, escríbalo en los comentarios, actualizaré el artículo.

Varios parámetros


Hasta este momento, solo teníamos un parámetro para la animación. La pregunta puede surgir, pero ¿qué pasa si se pasa más de un parámetro al modificador? ¿Y si ambos necesitan ser animados al mismo tiempo? Así es como con el modificador de marco (ancho: alto :) por ejemplo. Después de todo, podemos cambiar simultáneamente el ancho y la altura de esta Vista, y queremos que el cambio ocurra en una animación, ¿cómo hacerlo? Después de todo, el parámetro AnimatableData es uno, ¿qué puedo sustituir?

Si nos fijamos, Apple solo tiene un requisito para animatableData. El tipo de datos que sustituya debe cumplir el protocolo VectorArithmetic. Este protocolo requiere que el objeto garantice las operaciones aritméticas mínimas necesarias para poder formar un segmento de dos valores e interpolar los puntos dentro de este segmento. Las operaciones necesarias para esto son suma, resta y multiplicación. La dificultad es que debemos realizar estas operaciones con un solo objeto que almacena varios parámetros. Aquellos. debemos empacar la lista completa de nuestros parámetros en un contenedor que será un vector. Apple proporciona un objeto de este tipo y nos ofrece utilizar una solución llave en mano para casos no muy difíciles. Se llama AnimatablePair.

Cambiemos la tarea un poco. Necesitamos un nuevo modificador que no solo mueva la barra verde, sino que también cambie su altura. Estos serán dos parámetros modificadores independientes. No daré el código completo de todos los cambios que deben hacerse, puede verlo en el github en el archivo SimpleBorderMove. Solo mostraré el modificador en sí:

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)
        }
    }
}


Agregué otro control deslizante y un botón para cambiar aleatoriamente ambos parámetros a la vez en la vista principal de SimpleView, pero no hay nada interesante, así que para el código completo, bienvenido al github.

Todo funciona, realmente obtenemos un cambio constante en el par de parámetros empaquetados en la tupla AnimatablePair. No está mal.

¿Nada confunde en esta implementación? Personalmente, me tensé cuando vi este diseño:

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

No indiqué en ningún lugar cuál de estos parámetros debería ir primero y qué segundo. ¿Cómo decide SwiftUI qué valor poner primero y qué valor en segundo? Bueno, ¿no coincide los nombres de los parámetros de la función con los nombres de los atributos de la estructura?

La primera idea fue el orden de los atributos en los parámetros de la función y sus tipos, como sucede con @EnvironmentObject. Allí, simplemente colocamos los valores en el cuadro, sin asignarles ninguna etiqueta, y luego los sacamos de allí, también sin indicar ninguna etiqueta. Allí, el tipo importa, y dentro de un tipo, el orden. En qué orden ponen en la caja, de la misma manera y lo obtienen. Intenté un orden diferente de los argumentos de la función, el orden de los argumentos para inicializar la estructura, el orden de los atributos de la estructura en sí, generalmente golpeó mi cabeza contra la pared, pero no podía confundir a SwiftUI para que comenzara a animar la posición con valores de altura y viceversa.

Entonces me di cuenta. Yo mismo indico qué parámetro será el primero y qué segundo en el getter. SwiftUI no necesita saber exactamente cómo inicializamos esta estructura. Puede obtener el valor de animatableData antes del cambio, obtenerlo después del cambio, calcular la diferencia entre ellos y devolver la misma diferencia, en proporción al intervalo de tiempo transcurrido, a nuestro configurador. Por lo general, no necesita saber nada sobre el valor en sí dentro de AnimatableData. Y si no confunde el orden de las variables en dos líneas adyacentes, entonces todo estará en orden, sin importar cuán complicada sea la estructura del resto del código.

Pero vamos a verlo. Después de todo, podemos crear nuestro propio vector contenedor (oh, me encanta, crear nuestra propia implementación de objetos existentes, tal vez lo hayas notado en un artículo anterior).



Describimos la estructura elemental, declaramos soporte para el protocolo VectorArithmetic, abrimos el error sobre el protocolo no conforme, hacemos clic en corregir, y obtenemos la declaración de todas las funciones requeridas y los parámetros calculados. Solo queda llenarlos.

De la misma manera, llenamos nuestro objeto con los métodos necesarios para el protocolo AdditiveArithmetic (VectorArithmetic incluye su soporte).

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
}

  • Pienso por qué necesitamos + y - obviamente.
  • La escala es una función de escala. Tomamos la diferencia "Fue - se ha convertido" y la multiplicamos por la etapa actual de la animación (de 0 a 1). "Se convirtió en + Diferencia * (1 - Etapa)" y habrá un valor actual que deberíamos obtener en animatableData
  • Probablemente se necesite cero para inicializar nuevos objetos cuyos valores se utilizarán para la animación. La animación usa .zero al principio, pero no pude entender exactamente cómo. Sin embargo, no creo que esto sea importante.
  • magnitudeSquared es un producto escalar de un vector dado consigo mismo. Para el espacio bidimensional, esto significa la longitud del vector al cuadrado. Probablemente esto se usa para poder comparar dos objetos entre sí, no por elementos, sino como un todo. Parece que no se utiliza con fines de animación.

En términos generales, las funciones “- =” “+ =” también se incluyen en el soporte de protocolo, pero para la estructura se pueden generar automáticamente en este formulario

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

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


Para mayor claridad, expuse toda esta lógica en forma de diagrama. Se puede hacer clic en la imagen. Lo que obtenemos durante la animación se resalta en rojo: cada siguiente tick (1/60 segundos) el temporizador da un nuevo valor de t, y nosotros, en la configuración de nuestro modificador, obtenemos un nuevo valor de animatableData. Así es como funciona la animación bajo el capó. Al mismo tiempo, es importante comprender que un modificador es una estructura almacenada, y se utiliza una copia del modificador actual con un nuevo estado actual para mostrar la animación.





Por qué AnimatableData solo puede ser una estructura


Hay un punto más. No puede usar clases como un objeto AnimatableData. Formalmente, puede describir para una clase todos los métodos necesarios del protocolo correspondiente, pero esto no despegará, y he aquí por qué. Como sabe, una clase es un tipo de datos de referencia y una estructura es un tipo de datos basado en valores. Cuando crea una variable basada en otra, en el caso de una clase, copia un enlace a este objeto, y en el caso de una estructura, crea un nuevo objeto basado en los valores del existente. Aquí hay un pequeño ejemplo que ilustra esta diferencia:

    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)

Con la animación sucede exactamente lo mismo. Tenemos un objeto AnimatableData que representa la diferencia entre "fue" y "se convirtió". Necesitamos calcular parte de esta diferencia para reflejar en la pantalla. Para hacer esto, debemos copiar esta diferencia y multiplicarla por un número que represente la etapa actual de la animación. En el caso de la estructura, esto no afectará la diferencia en sí, pero en el caso de la clase sí lo hará. El primer cuadro que dibujamos es el estado "era". Para hacer esto, necesitamos calcular Acero + Diferencia * Etapa actual - Diferencia. En el caso de la clase, en el primer cuadro multiplicamos la diferencia por 0, poniéndola a cero, y todos los cuadros subsiguientes se dibujan de modo que la diferencia = 0. i.e. la animación parece estar dibujada correctamente, pero de hecho vemos una transición instantánea de un estado a otro, como si no hubiera animación.

Probablemente pueda escribir algún tipo de código de bajo nivel que cree nuevas direcciones de memoria para el resultado de la multiplicación, pero ¿por qué? Simplemente puede usar estructuras, se crean para eso.

Para aquellos que desean comprender a fondo cómo SwiftUI calcula los valores intermedios, por qué operaciones y en qué momento, los mensajes se envían a la consola en el proyecto . Además, inserté sleep 0.1 segundos allí para simular cálculos intensivos en recursos dentro de la animación, diviértete :)

Animación de pantalla: .transition ()


Hasta este punto, hablamos sobre animar un cambio en un valor pasado a un modificador o forma. Estas son herramientas bastante poderosas. Pero hay otra herramienta que también usa animación: esta es la animación de la aparición y desaparición de la Vista.

En el último artículo, hablamos sobre el hecho de que en el estilo declarativo de if-else, esto no tiene ningún control sobre el flujo de código en tiempo de ejecución, sino más bien una vista de Schrödinger. Este es un contenedor que contiene dos vistas al mismo tiempo, que decide cuál mostrar de acuerdo con una determinada condición. Si pierde el bloque else, se muestra EmptyView en lugar de la segunda vista.

El cambio entre las dos vistas también se puede animar. Para hacer esto, use el modificador .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()
        }
    }
}

Veamos cómo funciona. En primer lugar, de antemano, incluso en la etapa de inicialización de la vista principal, creamos y colocamos varias Vistas en la matriz. La matriz es del tipo AnyView, porque los elementos de la matriz deben tener el mismo tipo, de lo contrario no se pueden usar en ForEach. Tipo de resultado opaco del artículo anterior , ¿recuerda?

A continuación, prescribimos la enumeración de los índices de esta matriz, y para cada uno de ellos mostramos la vista por este índice. Nos vemos obligados a hacer esto, y no iterar sobre Vista inmediatamente, porque para trabajar con ForEach, necesitamos asignar un identificador interno a cada elemento para que SwiftUI pueda iterar sobre el contenido de la colección. Como alternativa, tendríamos que crear un identificador proxy en cada Vista, pero ¿por qué si se pueden usar índices?

Envolvemos cada vista de la colección en una condición y la mostramos solo si está activa. Sin embargo, la construcción if-else simplemente no puede existir aquí, el compilador lo toma para el control de flujo, por lo que ponemos todo esto en Group para que el compilador comprenda exactamente qué es View, o más precisamente, instrucciones para que ViewBuilder cree un contenedor de ConditionalContent opcional. <Ver1, Ver2>.

Ahora, al cambiar el valor de currentViewInd, SwiftUI oculta la vista activa anterior y muestra la actual. ¿Qué le parece esta navegación dentro de la aplicación?


Todo lo que queda por hacer es colocar el cambio currentViewInd en el contenedor withAnimation, y cambiar entre ventanas se hará más sencillo.

Agregue el modificador .transition, especificando .scale como parámetro. Esto hará que la animación de la aparición y desaparición de cada una de estas vistas sea diferente, utilizando la escala en lugar de la transparencia utilizada por SwiftUI por defecto.

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


Tenga en cuenta que la vista aparece y desaparece con la misma animación, solo la desaparición se desplaza en el orden inverso. De hecho, podemos asignar animaciones individualmente para la apariencia y la desaparición de una vista. Para esto se usa una transición asimétrica.

                    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 misma animación .scale se usa para aparecer en la pantalla, pero ahora hemos especificado los parámetros para su uso. No comienza con un tamaño cero (punto), sino con un tamaño de 0.1 del habitual. Y la posición inicial de la pequeña ventana no está en el centro de la pantalla, sino que se desplaza hacia el borde izquierdo. Además, no una transición es responsable de la apariencia, sino dos. Se pueden combinar con .combined (con :). En este caso, hemos agregado transparencia.

La desaparición de la vista ahora está representada por otra animación: barrer el borde derecho de la pantalla. Hice la animación un poco más lenta para que puedas echarle un vistazo.

Y como siempre, no puedo esperar para escribir mi propia versión de animación de tránsito. Esto es incluso más simple que las formas animadas o modificadores.

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))
    }
}


Para empezar, escribimos el modificador habitual en el que transferimos un cierto número: el ángulo de rotación en grados, así como el punto en relación con el cual se produce esta rotación. Luego, ampliamos el tipo AnyTransition con dos funciones. Podría haber sido uno, pero me pareció más conveniente. Me resultó más fácil asignar nombres de voz a cada uno de ellos que controlar los grados de rotación directamente en la propia vista.

El tipo AnyTransition tiene un método modificador estático, en el que pasamos dos modificadores, y obtenemos un objeto AnyTransition que describe una transición suave de un estado a otro. identidad es el modificador de estado normal de la Vista animada. Activo es el estado del comienzo de la animación para la apariencia de la vista, o el final de la animación para la desaparición, es decir. el otro extremo del segmento, los estados dentro de los cuales se interpolarán.

Entonces, spinIn implica que lo usaré para hacer que la vista aparezca desde fuera de la pantalla (o el espacio asignado para la Vista) girando en sentido horario alrededor del punto especificado. spinOut significa que la vista desaparecerá de la misma manera, girando alrededor del mismo punto, también en sentido horario.

Según mi idea, si usas el mismo punto para la aparición y desaparición de la Vista, obtienes el efecto de rotar toda la pantalla alrededor de este punto.

Toda la animación se basa en la mecánica de modificación estándar. Si escribe un modificador totalmente personalizado, debe implementar los requisitos del protocolo AnimatableModifier, como lo hicimos anteriormente con TwoParameterBorder, o usar los modificadores integrados que proporcionan su propia animación predeterminada. En este caso, confié en la animación incorporada .rotationEffect () dentro de mi modificador SpinTransitionModifier.

El modificador .transition () solo aclara qué considerar como el punto inicial y final de la animación. Si necesitamos solicitar el estado AnimatableData antes de comenzar la animación, para solicitar el modificador AnimatableData del estado actual, calcular la diferencia y luego animar la disminución de 1 a 0, entonces .transition () solo cambia los datos originales. No está vinculado al estado de su Vista; no está basado en él. Usted especifica explícitamente el estado inicial y final usted mismo, de ellos obtiene AnimatableData, calcula la diferencia y la anima. Luego, al final de la animación, su Vista actual aparece en primer plano.

Por cierto, la identidad es un modificador que permanecerá aplicado a su Vista al final de la animación. De lo contrario, un error aquí conduciría a saltos al final de la animación de aparición y al comienzo de la animación de desaparición. Por lo tanto, la transición puede considerarse como "dos en uno", aplicando un modificador específico directamente a Ver + la capacidad de animar sus cambios cuando la Vista aparece y desaparece.

Honestamente, este mecanismo de control de animación me parece muy fuerte, y lamento un poco que no podamos usarlo para ninguna animación. No me negaría a tal creación de animación cerrada sin fin. Sin embargo, hablaremos de eso en el próximo artículo.

Para ver mejor cómo ocurre el cambio en sí, reemplacé nuestra Vista de prueba con cuadrados elementales, firmados con números y enmarcados.

                    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)))
                        }
                    }


Y para hacer este movimiento aún mejor, eliminé .clipped () del modificador SpinTransitionModifier:

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


Por cierto, ahora necesitamos SpinTransitionModifier en nuestro propio modificador por completo. Fue creado solo para combinar los dos modificadores, rotacionEfecto y recortado () en uno, para que la animación de rotación no vaya más allá del alcance seleccionado para nuestra Vista. Ahora, podemos usar .rotationEffect () directamente dentro de .modifier (), no necesitamos un intermediario en forma de SpinTransitionModifier.

Cuando la vista muere


Un punto interesante es el ciclo de vida Ver si se coloca en un if-else. La vista, aunque iniciada y registrada como un elemento de matriz, no se almacena en la memoria. Toda ellaEstadolos parámetros se restablecen a los valores predeterminados la próxima vez que aparecen en la pantalla. Esto es casi lo mismo que la inicialización. A pesar del hecho de que la estructura del objeto en sí misma todavía existe, el render lo eliminó de su campo de visión, ya que no lo es. Por un lado, esto reduce el uso de memoria. Si tiene una gran cantidad de Vistas complejas en la matriz, el renderizado debería dibujarlas todas constantemente, reaccionando a los cambios, esto afectó negativamente el rendimiento. Si no me equivoco, ese fue el caso antes de la actualización de Xcode 11.3. Ahora, las vistas inactivas se descargan de la memoria de representación.

Por otro lado, debemos mover todos los estados importantes más allá del alcance de esta Vista. Para esto, es mejor usar las variables @EnvironmentObject.

Volviendo al ciclo de vida, también debe tenerse en cuenta que el modificador .onAppear {}, si está registrado dentro de esta Vista, funciona inmediatamente después de cambiar la condición y la apariencia de la Vista en la pantalla, incluso antes de que comience la animación. En consecuencia, onDisappear {} se activa después del final de la animación de desaparición. Tenga esto en cuenta si planea usarlos con animación de transición.

¿Que sigue?


Uf Resultó bastante voluminoso, pero en detalle y, espero, inteligible. Honestamente, esperaba hablar sobre la animación del arco iris como parte de un artículo, pero no pude detenerme a tiempo con los detalles. Así que espera la continuación.

La siguiente parte nos espera:

  • uso de gradientes: lineal, circular y angular: todo será útil
  • El color no es color en absoluto: elija sabiamente.
  • animación en bucle: cómo comenzar y cómo parar, y cómo parar de inmediato (sin animación, cambiar la animación, sí, también hay una)
  • animación de flujo actual: prioridades, anulaciones, animación diferente para diferentes objetos
  • detalles sobre los tiempos de animación: manejaremos los tiempos tanto en la cola como en la melena, hasta nuestra propia implementación de timingCurve (oh, mantenme siete :))
  • cómo averiguar el momento actual de la animación reproducida
  • Si SwiftUI no es suficiente

Hablaré de todo esto en detalle utilizando el ejemplo de creación de animación de arcoíris, como en la imagen:


No tomé el camino fácil, pero recogí todos los rastrillos que pude alcanzar, incorporando esta animación en los principios descritos anteriormente. La historia sobre esto debería ser muy informativa y rica en trucos y todo tipo de hacks, sobre los cuales hubo pocos informes, y que serán útiles para aquellos que deciden convertirse en pioneros en SwiftUI. Aparecerá aproximadamente en una semana o dos. Por cierto, puedes suscribirte para no perderte. Pero esto, por supuesto, solo si el material le parece útil y se aprueba el método de presentación. Luego, su suscripción ayudará a llevar rápidamente nuevos artículos a la cima, llevándolos a un público más amplio desde el principio. De lo contrario, escriba en los comentarios lo que está mal.

All Articles