SwiftUI nas prateleiras: Animação. Parte 1

imagem

Recentemente me deparei com um artigo novo, no qual os caras tentavam reproduzir um conceito interessante usando o SwiftUI. Aqui está o que eles fizeram:

imagem

estudei o código com interesse, mas experimentei alguma frustração. Não, não no sentido de que eles fizeram algo errado, de maneira alguma. Eu simplesmente não aprendi com o código deles praticamente nada de novo. Sua implementação é mais sobre Combinar do que sobre animação. E eu decidi construir meu lunopark para escrever meu artigo sobre animação no SwiftUI, implementando sobre o mesmo conceito, mas usando 100% dos recursos da animação interna, mesmo que não seja muito eficaz. Estudar - até o fim. Para experimentar - então com um brilho :)

Aqui está o que eu tenho:


No entanto, para uma divulgação completa do tópico, tive que falar com alguns detalhes sobre o básico. O texto acabou sendo volumoso e, portanto, eu o dividi em dois artigos. Aqui está a primeira parte - em vez disso, um tutorial sobre animação em geral, não diretamente relacionado à animação arco-íris, que discutirei em detalhes no próximo artigo.

Neste artigo, falarei sobre o básico, sem o qual você pode facilmente se confundir em exemplos mais complexos. Muito do que vou falar, de uma forma ou de outra, já foi descrito em artigos em inglês como esta série ( 1 , 2 , 3 , 4) Por outro lado, concentrei-me não apenas em enumerar as maneiras de trabalhar, mas em descrever exatamente como isso funciona. E como sempre, eu experimentei muito, então me apressei em compartilhar os resultados mais interessantes.

aviso: sob o gato há muitas fotos e animações gif.

TLDR


O projeto está disponível no github . Você pode ver o resultado atual com a animação do arco-íris em TransitionRainbowView (), mas eu não me apressei em seu lugar, mas esperei pelo próximo artigo. Além disso, ao prepará-lo, eu penteio um pouco o código.

Neste artigo, discutiremos apenas o básico e afetamos apenas o conteúdo da pasta Bases.

Introdução


Eu admito, não ia escrever este artigo agora. Eu tinha um plano segundo o qual um artigo sobre animação deveria ser o terceiro ou mesmo o quarto consecutivo. No entanto, eu não pude resistir, eu realmente queria fornecer um ponto de vista alternativo.

Quero fazer uma reserva imediatamente. Não acredito que tenham sido cometidos erros no artigo mencionado ou que a abordagem usada esteja incorreta. De modo nenhum. Ele constrói um modelo de objeto do processo (animação) que, respondendo ao sinal recebido, começa a fazer alguma coisa. No entanto, quanto a mim, este artigo provavelmente revela trabalho com a estrutura Combine. Sim, essa estrutura é uma parte importante do SwiftUI, mas é mais sobre o estilo de reação em geral do que sobre animação.

Minha opção certamente não é mais elegante, mais rápida e mais fácil de manter. No entanto, ele revela muito melhor o que está sob o capô do SwiftUI e, de fato, esse era o objetivo do artigo - descobrir primeiro.

Como eu disse em um artigo anteriorpela SwiftUI, comecei meu mergulho no mundo do desenvolvimento móvel imediatamente com o SwiftUI, ignorando o UIKit. Obviamente, isso tem um preço, mas há vantagens. Não estou tentando morar em um novo mosteiro, de acordo com a antiga Carta. Honestamente, ainda não conheço nenhuma carta, então não rejeito a nova. É por isso que, parece-me, este artigo pode ser útil não apenas para iniciantes, como eu, mas também para aqueles que estudam o SwiftUI que já têm experiência na forma de desenvolvimento no UIKit. Parece-me que muitas pessoas não têm um visual renovado. Não faça o mesmo, tentando encaixar uma nova ferramenta nos desenhos antigos, mas mude sua visão de acordo com novas possibilidades.

Nós 1c-nicks passamos por isso com "formas controladas". Esse é um tipo de SwiftUI no mundo dos 1s, que aconteceu mais de 10 anos atrás. De fato, a analogia é bastante precisa, porque os formulários gerenciados são apenas uma nova maneira de desenhar uma interface. No entanto, ele mudou completamente a interação cliente-servidor do aplicativo como um todo, e a imagem do mundo nas mentes dos desenvolvedores em particular. Isso não foi fácil, eu mesmo não quis estudá-lo por cerca de 5 anos, porque Eu pensei que muitas das oportunidades que foram cortadas lá eram simplesmente necessárias para mim. Mas, como a prática demonstrou, a codificação em formulários gerenciados não é apenas possível, mas apenas necessária.

No entanto, não vamos mais falar sobre isso. Eu recebi um guia detalhado e independente que não tem nenhuma referência ou outros links para o artigo mencionado ou o 1º passado. Passo a passo, vamos mergulhar nos detalhes, características, princípios e limitações. Vai.

Forma de animação


Como a animação funciona em geral


Portanto, a idéia principal da animação é a transformação de uma mudança específica e discreta em um processo contínuo. Por exemplo, o raio do círculo era de 100 unidades, tornou-se 50 unidades. Sem animação, a mudança acontecerá instantaneamente, com animação - sem problemas. Como funciona? Muito simples. Para mudanças suaves, precisamos interpolar vários valores dentro do segmento “Era ... Tornou-se”. No caso do raio, teremos que desenhar vários círculos intermediários com um raio de 98 unidades, 95 unidades, 90 unidades ... 53 unidades e, finalmente, 50 unidades. O SwiftUI pode fazer isso de maneira fácil e natural, basta agrupar o código que executa essa alteração com Animação {...}. Parece mágico ... Até que você queira implementar algo um pouco mais complicado do que "olá mundo".

Vamos seguir para os exemplos. O objeto mais simples e compreensível para animação é considerado animação de formulários. Forma (ainda chamarei a estrutura em conformidade com o protocolo de forma) no SwiftUI é uma estrutura com parâmetros que podem se ajustar a esses limites. Essa. é uma estrutura que possui o corpo da função (em rect: CGRect) -> Caminho. Todo o tempo de execução necessário para desenhar esse formulário é solicitar seu contorno (o resultado da função é um objeto do tipo Path, na verdade, é uma curva de Bezier) para o tamanho necessário (especificado como um parâmetro da função, um retângulo do tipo CGRect).

Forma é uma estrutura armazenada. Ao inicializá-lo, você armazena nos parâmetros tudo o que precisa para desenhar seu contorno. O tamanho da seleção para este formulário pode mudar, então tudo o que é necessário é obter um novo valor de caminho para o novo quadro CGRect e pronto.

Vamos começar a codificar já:

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


Então, temos um círculo (Circle ()), cujo raio podemos mudar usando o controle deslizante. Isso acontece sem problemas, pois o controle deslizante nos fornece todos os valores intermediários. No entanto, quando você clica no botão "definir raio padrão", a alteração também não ocorre instantaneamente, mas de acordo com a instrução withAnimation (.linear (duration: 1)). Linearmente, sem aceleração, esticada por 1 segundo. Classe! Nós dominamos a animação! Discordamos :)

Mas e se quisermos implementar nossa própria forma e animar suas mudanças? É difícil fazer isso? Vamos checar.

Fiz uma cópia do Circle da seguinte maneira:

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

O raio do círculo é calculado como a metade da largura e altura da borda da área da tela alocada para nós. Se a largura for maior que a altura, partimos do meio da borda superior (Nota 1), descrevemos o círculo completo no sentido horário (Nota 2) e fechamos nosso esboço. Se a altura for maior que a largura, partimos do meio da borda direita, também descrevemos o círculo completo no sentido horário e fechamos o 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.

Vamos verificar como nosso novo círculo responderá às alterações no bloco 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")
            }
        }
    }
}


Uau! Aprendemos como criar nossas próprias imagens de forma livre e animá-las! É assim?

Na verdade não. Todo o trabalho aqui é feito pelo modificador .frame (width: self.radius * 2, height: self.radius * 2). Dentro do bloco withAnimation {...} mudamosEstadouma variável, ele envia um sinal para reinicializar CustomCircleView () com um novo valor de raio, esse novo valor cai no modificador .frame () e esse modificador já pode animar alterações de parâmetro. Nosso formulário CustomCircle () reage a isso com animação, porque não depende de nada além do tamanho da área selecionada para ele. A alteração da área ocorre com a animação (ou seja, gradualmente, interpolando os valores intermediários entre o que foi e se tornou), portanto, nosso círculo é desenhado com a mesma animação.

Vamos simplificar (ou ainda complicar?) Nossa forma um pouco. Não calcularemos o raio com base no tamanho da área disponível, mas transferiremos o raio na forma final, ou seja, torná-lo um parâmetro de estrutura armazenada.

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


Bem, a magia está irremediavelmente perdida.

Excluímos o modificador frame () de nosso CustomCircleView (), transferindo a responsabilidade pelo tamanho do círculo para a própria forma e a animação desapareceu. Mas isso não importa; ensinar um formulário para animar alterações em seus parâmetros não é muito difícil. Para fazer isso, você precisa implementar os requisitos do 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! A magia está de volta!

E agora podemos dizer com segurança que nosso formulário é realmente animado - pode refletir alterações em seus parâmetros com animação. Demos ao sistema uma janela onde ele pode compactar os valores interpolados necessários para a animação. Se houver uma janela, as alterações serão animadas. Caso contrário, as alterações ocorrem sem animação, ou seja, imediatamente. Nada complicado, certo?

AnimatableModifier


Como animar alterações dentro de uma Visualização


Mas vamos diretamente para o View. Suponha que desejemos animar a posição de um elemento dentro de um contêiner. No nosso caso, será um retângulo simples de cor verde e uma largura de 10 unidades. Vamos animar sua posição 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
    }
}


Classe! Trabalho! Agora sabemos tudo sobre animação!

Na verdade não. Se você olhar para o console, veremos o seguinte:
Posição de
cálculo do init do BorderView : 0.4595176577568054
Posição de
cálculo do init do BorderView: 0.468130886554718
Posição de
cálculo do init do BorderView : 0.0

Em primeiro lugar, cada alteração no valor da posição usando o controle deslizante faz com que o BorderView seja reinicializado com o novo valor. É por isso que vemos um movimento suave da linha verde após o controle deslizante, o controle deslizante simplesmente relata com frequência uma alteração na variável e parece uma animação, mas não é. Usar o controle deslizante é realmente conveniente quando você depura a animação. Você pode usá-lo para rastrear alguns estados de transição.

Em segundo lugar, vemos que a posição de cálculo simplesmente se tornou igual a 0, e não há registros intermediários, como foi o caso da animação correta do círculo. Por quê?

A coisa, como no exemplo anterior, está no modificador. Dessa vez, o modificador .offset () obtém o novo valor de indentação e anima a alteração em si. Essa. na verdade, não é a alteração no parâmetro position que pretendemos ser animados, mas a alteração horizontal do recuo no modificador .offset () derivado dele. Nesse caso, é uma substituição inofensiva, o resultado é o mesmo. Mas desde que eles chegaram, vamos nos aprofundar. Vamos fazer nosso próprio modificador, que receberá a posição (de 0 a 1) na entrada, receberá o tamanho da área disponível e calculará o recuo.

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

No BorderView original, respectivamente, o GeometryReader não é mais necessário, bem como a função para calcular o recuo:

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


Sim, ainda usamos o modificador .offset () dentro do nosso modificador, mas depois adicionamos o modificador .animation (nil), que bloqueia nossa própria animação de deslocamento. Entendo que, nesse estágio, você possa decidir que basta remover esse bloqueio, mas não chegaremos ao fundo da verdade. E a verdade é que nosso truque com animatableData for BorderView não funciona. De fato, se você examinar a documentação do protocolo Animatable , notará que a implementação deste protocolo é suportada apenas para AnimatableModifier, GeometryEffect e Shape. Vista não está entre eles.

A abordagem correta é animar modificações


A abordagem em si, quando pedimos ao View para animar algumas alterações, estava incorreta. Para o View, você não pode usar a mesma abordagem dos formulários. Em vez disso, a animação precisa ser incorporada em cada modificador. A maioria dos modificadores internos já suporta animação pronta para uso. Se você deseja animação para seus próprios modificadores, pode usar o protocolo AnimatableModifier em vez de ViewModifier. E aí você pode implementar a mesma coisa que ao animar alterações de forma, como fizemos acima.

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


Agora está tudo certo. As mensagens no console ajudam a entender que nossa animação realmente funciona e a animação. (Nil) dentro do modificador não interfere em nada. Mas ainda vamos descobrir exatamente como isso funciona.

Primeiro, você precisa entender o que é um modificador.


Aqui temos uma vista. Como eu disse na parte anterior, esta é uma estrutura com parâmetros armazenados e instruções de montagem. Essa instrução, em geral, não contém uma sequência de ações, que é o código usual que escrevemos em um estilo não declarativo, mas uma lista simples. Ele lista a outra Visualização, os modificadores aplicados a eles e os contêineres nos quais estão incluídos. Ainda não estamos interessados ​​em contêineres, mas vamos falar mais sobre modificadores.

Um modificador é novamente uma estrutura com parâmetros armazenados e Exibir instruções de processamento. Esta é realmente a mesma instrução que a View - podemos usar outros modificadores, usar contêineres (por exemplo, usei o GeometryReader um pouco mais alto) e até outra View. Mas só temos conteúdo recebido e precisamos alterá-lo de alguma forma usando esta instrução. Os parâmetros do modificador fazem parte da instrução. Mas o mais interessante é que eles são armazenados.

Em um artigo anterior, eu disse que a instrução em si não é armazenada, que é lançada toda vez após a atualização da Visualização. Tudo é assim, mas há uma nuance. Como resultado do trabalho desta instrução, não temos uma imagem completa, como eu disse anteriormente - foi uma simplificação. Os modificadores não desaparecem após a operação desta instrução. Eles permanecem assim enquanto a Visualização pai existe.

Algumas analogias primitivas


Como descreveríamos uma tabela em um estilo declarativo? Bem, listaríamos 4 pernas e uma bancada. Eles os combinariam em algum tipo de contêiner e, com a ajuda de alguns modificadores, prescreveriam como eles são presos um ao outro. Por exemplo, cada perna indicaria a orientação em relação à bancada e a posição - qual perna está presa em qual canto. Sim, podemos descartar as instruções após a montagem, mas os pregos permanecerão na mesa. O mesmo acontece com os modificadores. Na saída da função do corpo, não temos uma tabela completa. Usando body, criamos elementos de tabela (visualização) e elementos de fixação (modificadores) e colocamos tudo em gavetas. A mesa em si é montada por um robô. Quais fixadores você colocar em uma caixa para cada perna, você terá uma mesa dessas.

A função .modifier (BorderPosition (position: position)), com a qual transformamos a estrutura BorderPosition em um modificador, apenas coloca um parafuso adicional na gaveta na perna da mesa. A estrutura BorderPosition é esse parafuso. A renderização, no momento da renderização, pega essa caixa, tira uma perna dela (Rectangle () no nosso caso) e obtém sequencialmente todos os modificadores da lista, com os valores armazenados nelas. A função do corpo de cada modificador é uma instrução sobre como aparafusar uma perna em uma mesa com esse parafuso, e a própria estrutura com propriedades armazenadas, esse é o parafuso.

Por que é importante entender isso no contexto da animação? Porque a animação permite alterar os parâmetros de um modificador sem afetar os outros e, em seguida, renderizar novamente a imagem. Se você fizer o mesmo, alterando algumas@Stateparâmetros - isso causará a reinicialização da Vista aninhada, estruturas modificadoras etc., ao longo de toda a cadeia. Mas a animação não é.

De fato, quando alteramos o valor da posição quando pressionamos um botão, ele muda. Até o fim. Nenhum estado intermediário é armazenado na própria variável, o que não pode ser dito sobre o modificador. Para cada novo quadro, os valores dos parâmetros do modificador mudam de acordo com o progresso da animação atual. Se a animação durar 1 segundo, a cada 1/60 de segundo (o iphone mostra exatamente esse número de quadros por segundo), o valor animatableData dentro do modificador será alterado, e será lido pela renderização para renderização, após o que, após outros 1/60 de segundo, será alterado novamente e lido novamente pela renderização.

O que é característico, primeiro obtemos o estado final de toda a visualização, lembre-se, e só então o mecanismo de animação começa a colocar os valores de posição interpolados no modificador. O estado inicial não é armazenado em nenhum lugar. Em algum lugar nas entranhas do SwiftUI, apenas a diferença entre o estado inicial e o final é armazenada. Essa diferença é sempre multiplicada pela fração do tempo decorrido. É assim que o valor interpolado é calculado, que é subsequentemente substituído em animatableData.

Diferença =

aço
- era o valor atual = aço - diferença * (1 - tempo decorrido) tempo decorrido = tempo desde o inícioAnimations / DurationAnimations O

valor atual precisa ser calculado quantas vezes o número de quadros que precisamos mostrar.

Por que não é usado explicitamente? O fato é que o SwiftUI não armazena o estado inicial. Somente a diferença é armazenada: portanto, no caso de algum tipo de falha, você pode simplesmente desligar a animação e ir para o estado atual de "tornar-se".

Essa abordagem permite que você faça a animação reversível. Suponha que, em algum lugar no meio de uma animação, o usuário pressione um botão novamente e alteremos novamente o valor da mesma variável. Nesse caso, tudo o que precisamos fazer para vencer essa mudança lindamente é pegar "Atual" dentro da animação no momento da nova mudança como "It", lembrar a nova Diferença e iniciar uma nova animação baseada no novo "Tornou-se" e na nova "Diferença" . Sim, de fato, essas transições de uma animação para outra podem ser um pouco mais difíceis de simular a inércia, mas acho que o significado é compreensível.

O interessante é que a animação de cada quadro solicita o valor atual dentro do modificador (usando um getter). Isso, como você pode ver nos registros de serviço no log, é responsável pelo status de "Aço". Em seguida, usando o configurador, definimos o novo estado atual para esse quadro. Depois disso, para o próximo quadro, o valor atual do modificador é novamente solicitado - e novamente "Tornou-se", ou seja, O valor final para o qual a animação está se movendo. É provável que cópias de estruturas modificadoras sejam usadas para animação, e um getter de uma estrutura (um modificador real da Visualização real) seja usado para obter o valor "Steel", e um setter de outro (um modificador temporário usado para animação) é usado. Eu não criei uma maneira de garantir isso, mas por indicações indiretas tudo parece exatamente assim. De qualquer forma,As alterações na animação não afetam o valor armazenado da estrutura modificadora da Visualização atual. Se você tiver idéias sobre como descobrir exatamente o que exatamente acontece com o getter e o setter, escreva sobre isso nos comentários, atualizarei o artigo.

Vários parâmetros


Até o momento, tínhamos apenas um parâmetro para animação. A questão pode surgir, mas e se mais de um parâmetro for passado para o modificador? E se os dois precisam ser animados ao mesmo tempo? Veja como, com o modificador de quadro (width: height :), por exemplo. Afinal, podemos alterar simultaneamente a largura e a altura dessa visualização, e queremos que a alteração ocorra em uma animação, como fazê-lo? Afinal, o parâmetro AnimatableData é um, o que posso substituir nele?

Se você olhar, a Apple tem apenas um requisito para dados animable. O tipo de dados que você substitui deve satisfazer o protocolo VectorArithmetic. Esse protocolo requer que o objeto garanta as operações aritméticas mínimas necessárias para formar um segmento de dois valores e interpolar os pontos dentro desse segmento. As operações necessárias para isso são adição, subtração e multiplicação. A dificuldade é que devemos executar essas operações com um único objeto que armazena vários parâmetros. Essa. devemos empacotar a lista inteira de nossos parâmetros em um contêiner que será um vetor. A Apple fornece esse objeto imediatamente e nos oferece uma solução pronta para uso em casos não muito difíceis. É chamado AnimatablePair.

Vamos mudar um pouco a tarefa. Precisamos de um novo modificador que não apenas mova a barra verde, mas também mude sua altura. Estes serão dois parâmetros modificadores independentes. Não darei o código completo de todas as alterações que precisam ser feitas, você pode vê-lo no github no arquivo SimpleBorderMove. Vou mostrar apenas o modificador em si:

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


Adicionei outro controle deslizante e um botão para alterar aleatoriamente os dois parâmetros de uma só vez na visualização pai do SimpleView, mas não há nada interessante, portanto, para o código completo, seja bem-vindo ao github.

Tudo funciona, nós realmente temos uma mudança consistente no par de parâmetros compactados na tupla AnimatablePair. Não é ruim.

Nada confunde nesta implementação? Pessoalmente, fiquei tenso quando vi esse design:

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

Não indiquei em nenhum lugar qual desses parâmetros deve ir primeiro e qual segundo. Como o SwiftUI decide qual valor colocar em primeiro e qual valor em segundo? Bem, ele não corresponde aos nomes dos parâmetros da função com os nomes dos atributos da estrutura?

A primeira ideia foi a ordem dos atributos nos parâmetros da função e seus tipos, como acontece com @EnvironmentObject. Lá, simplesmente colocamos os valores na caixa, sem atribuir nenhum rótulo a eles, e então os tiramos de lá, também sem indicar nenhum rótulo. Lá, o tipo importa e, em um tipo, a ordem. Em que ordem eles colocam na caixa, da mesma maneira e a recebem. Tentei uma ordem diferente dos argumentos da função, a ordem dos argumentos para inicializar a estrutura, a ordem dos atributos da própria estrutura, geralmente batia minha cabeça contra a parede, mas não conseguia confundir o SwiftUI, de modo que ele começou a animar a posição com valores de altura e vice-versa.

Então me dei conta. Eu próprio indico qual parâmetro será o primeiro e qual segundo no getter. O SwiftUI não precisa saber exatamente como inicializamos essa estrutura. Ele pode obter o valor animatableData antes da alteração, obtê-lo após a alteração, calcular a diferença entre eles e retornar a mesma diferença, dimensionada proporcionalmente ao intervalo de tempo decorrido, ao nosso setter. Geralmente, não é necessário saber nada sobre o valor em si dentro do AnimatableData. E se você não confundir a ordem das variáveis ​​em duas linhas adjacentes, tudo estará em ordem, não importa quão complicada seja a estrutura do restante do código.

Mas vamos dar uma olhada. Afinal, podemos criar nosso próprio vetor de contêiner (ah, adoro isso, criar nossa própria implementação de objetos existentes, você deve ter notado isso em um artigo anterior).



Descrevemos a estrutura elementar, declaramos suporte ao protocolo VectorArithmetic, abrimos o erro sobre o protocolo não conforme, clicamos em correção e obtemos a declaração de todas as funções necessárias e parâmetros calculados. Resta apenas preenchê-los.

Da mesma forma, preenchemos nosso objeto com os métodos necessários para o protocolo AdditiveArithmetic (o VectorArithmetic inclui seu suporte).

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
}

  • Penso porque precisamos de + e - obviamente.
  • escala é uma função da escala. Pegamos a diferença “Era - Tornou-se” e a multiplicamos pelo estágio atual da animação (de 0 a 1). “Tornou-se + Diferença * (1 - Estágio)” e haverá um valor atual que deveremos excluir em dados
  • Provavelmente é necessário zero para inicializar novos objetos cujos valores serão usados ​​para animação. A animação usa .zero logo no início, mas não consegui descobrir exatamente como. No entanto, não acho que isso seja importante.
  • magnitudeSquared é um produto escalar de um determinado vetor consigo mesmo. Para espaço bidimensional, isso significa o comprimento do vetor ao quadrado. Provavelmente, isso é usado para comparar dois objetos entre si, não por elementos, mas como um todo. Parece não ser usado para fins de animação.

De um modo geral, as funções “- =” “+ =” também estão incluídas no suporte ao protocolo, mas para a estrutura elas podem ser geradas automaticamente neste formulário

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

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


Para maior clareza, expus toda essa lógica na forma de um diagrama. A imagem é clicável. O que obtemos durante a animação é destacado em vermelho - a cada próximo tick (1/60 segundo), o timer fornece um novo valor de t, e nós, no setter do nosso modificador, obtemos um novo valor de animatableData. É assim que a animação funciona sob o capô. Ao mesmo tempo, é importante entender que um modificador é uma estrutura armazenada e uma cópia do modificador atual com um novo estado atual é usada para exibir a animação.





Por que o AnimatableData pode ser apenas uma estrutura


Há mais um ponto. Você não pode usar classes como um objeto AnimatableData. Formalmente, você pode descrever para uma classe todos os métodos necessários do protocolo correspondente, mas isso não decola, e aqui está o porquê. Como você sabe, uma classe é um tipo de dados de referência e uma estrutura é um tipo de dados baseado em valor. Quando você cria uma variável com base em outra, no caso de uma classe, copia um link para esse objeto e, no caso de uma estrutura, cria um novo objeto com base nos valores da existente. Aqui está um pequeno exemplo que ilustra essa diferença:

    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)

Com animação, exatamente a mesma coisa acontece. Temos um objeto AnimatableData que representa a diferença entre "was" e "tornou-se". Precisamos calcular parte dessa diferença para refletir na tela. Para fazer isso, devemos copiar essa diferença e multiplicá-la por um número que represente o estágio atual da animação. No caso da estrutura, isso não afetará a diferença em si, mas no caso da classe, afetará. O primeiro quadro que desenhamos é o estado "era". Para fazer isso, precisamos calcular Aço + Diferença * Estágio Atual - Diferença. No caso da classe, no primeiro quadro multiplicamos a diferença por 0, zerando-o e todos os quadros subsequentes são desenhados para que a diferença = 0. a animação parece ter sido desenhada corretamente, mas, de fato, vemos uma transição instantânea de um estado para outro, como se não houvesse animação.

Você provavelmente pode escrever algum tipo de código de baixo nível que crie novos endereços de memória para o resultado da multiplicação - mas por quê? Você pode simplesmente usar estruturas - elas são criadas para isso.

Para aqueles que querem entender completamente como o SwiftUI calcula valores intermediários, por quais operações e em que momento, as mensagens são enviadas ao console no projeto . Além disso, eu inseri o sono 0,1 segundo lá para simular cálculos com muitos recursos dentro da animação, divirta-se :)

Animação de tela: .transition ()


Até esse ponto, falamos sobre animar uma alteração em um valor passado para um modificador ou formulário. Essas são ferramentas bastante poderosas. Mas há outra ferramenta que também usa animação - essa é a animação da aparência e desaparecimento da Visualização.

No último artigo, falamos sobre o fato de que, no estilo declarativo de if-else, isso não é controle sobre o fluxo de código no tempo de execução, mas uma visão de Schrodinger. Este é um contêiner que contém duas visualizações ao mesmo tempo, que decide qual delas mostrar de acordo com uma determinada condição. Se você perder o bloco else, o EmptyView será exibido em vez da segunda exibição.

A alternância entre as duas visualizações também pode ser animada. Para fazer isso, use o 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()
        }
    }
}

Vamos ver como isso funciona. Antes de mais nada, mesmo no estágio de inicialização da visualização pai, criamos e colocamos várias visualizações na matriz. A matriz é do tipo AnyView, porque os elementos da matriz devem ter o mesmo tipo, caso contrário, eles não podem ser usados ​​no ForEach. Tipo de resultado opaco do artigo anterior , lembra?

Em seguida, prescrevemos a enumeração dos índices dessa matriz e para cada um deles exibimos a visualização por esse índice. Somos forçados a fazer isso, e não iteramos sobre o View imediatamente, porque, para trabalhar com o ForEach, precisamos atribuir um identificador interno a cada elemento, para que o SwiftUI possa interagir com o conteúdo da coleção. Como alternativa, teríamos que criar um identificador de proxy em cada Visualização, mas por que, se índices podem ser usados?

Envolvemos cada visualização da coleção em uma condição e a mostramos apenas se estiver ativa. No entanto, a construção if-else simplesmente não pode existir aqui, o compilador a utiliza para controle de fluxo, por isso colocamos tudo isso em Group para que o compilador compreenda exatamente o que é View, ou mais precisamente, instruções para o ViewBuilder criar um contêiner ConditionalContent opcional <Visão1, Visão2>.

Agora, ao alterar o valor de currentViewInd, o SwiftUI oculta a visualização ativa anterior e mostra a atual. Como você gosta dessa navegação dentro do aplicativo?


Tudo o que falta fazer é colocar a alteração currentViewInd no wrapper withAnimation, e a alternância entre janelas ficará suave.

Adicione o modificador .transition, especificando .scale como o parâmetro. Isso tornará a animação da aparência e do desaparecimento de cada uma dessas visualizações diferentes - usando o dimensionamento em vez da transparência usada pelo SwiftUI padrão.

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


Observe que a exibição aparece e desaparece com a mesma animação, apenas o desaparecimento rola na ordem inversa. De fato, podemos atribuir animações individualmente para a aparência e o desaparecimento de uma exibição. Uma transição assimétrica é usada para isso.

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


A mesma animação .scale é usada para aparecer na tela, mas agora especificamos os parâmetros para seu uso. Não começa com um tamanho zero (ponto), mas com um tamanho de 0,1 do normal. E a posição inicial da pequena janela não está no centro da tela, mas mudou para a borda esquerda. Além disso, nenhuma transição é responsável pela aparência, mas duas. Eles podem ser combinados com .combined (com :). Nesse caso, adicionamos transparência.

O desaparecimento da visualização agora é renderizado por outra animação - varrendo a borda direita da tela. Eu fiz a animação um pouco mais lenta para que você possa dar uma olhada.

E como sempre, mal posso esperar para escrever minha própria versão da animação de transporte público. Isso é ainda mais simples que formulários ou modificadores animados.

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 começar, escrevemos o modificador usual no qual transferimos um certo número - o ângulo de rotação em graus, bem como o ponto em relação ao qual essa rotação ocorre. Em seguida, estendemos o tipo AnyTransition com duas funções. Poderia ter sido um, mas me pareceu mais conveniente. Achei mais fácil atribuir nomes falados a cada um deles do que controlar graus de rotação diretamente na própria Visualização.

O tipo AnyTransition possui um método de modificador estático, no qual passamos dois modificadores e obtemos um objeto AnyTransition que descreve uma transição suave de um estado para outro. identidade é o modificador de estado normal da vista animada. Ativo é o estado do início da animação para a aparência da exibição ou o final da animação para o desaparecimento, ou seja, a outra extremidade do segmento, os estados nos quais serão interpolados.

Portanto, spinIn implica que eu a usarei para fazer com que a visualização apareça fora da tela (ou o espaço alocado para a visualização) girando no sentido horário em torno do ponto especificado. spinOut significa que a visualização desaparecerá da mesma maneira, girando em torno do mesmo ponto, também no sentido horário.

De acordo com a minha ideia, se você usar o mesmo ponto para a aparência e o desaparecimento da Visualização, terá o efeito de girar a tela inteira em torno desse ponto.

Toda animação é baseada na mecânica modificadora padrão. Se você escrever um modificador totalmente personalizado, deverá implementar os requisitos do protocolo AnimatableModifier, como fizemos anteriormente com o TwoParameterBorder, ou usar os modificadores internos que fornecem sua própria animação padrão. Nesse caso, contei com a animação interna .rotationEffect () dentro do meu modificador SpinTransitionModifier.

O modificador .transition () apenas esclarece o que considerar como o ponto inicial e final da animação. Se precisarmos solicitar o estado AnimatableData antes de iniciar a animação, solicitar o modificador AnimatableData do estado atual, calcular a diferença e animar a diminuição de 1 a 0, então .transition () apenas altera os dados originais. Você não está apegado ao estado da sua Visualização; não se baseia nela. Você mesmo especifica explicitamente o estado inicial e final; a partir deles, obtém AnimatableData, calcula a diferença e a anima. Então, no final da animação, sua visão atual é destacada.

A propósito, a identidade é um modificador que permanecerá aplicado à sua Visualização no final da animação. Caso contrário, um erro aqui levaria a saltos no final da animação de aparência e no início da animação de desaparecimento. Portanto, a transição pode ser considerada como "dois em um" - aplicando um modificador específico diretamente ao View + a capacidade de animar suas alterações quando o View aparecer e desaparecer.

Honestamente, esse mecanismo de controle de animação me parece muito forte e lamento não podermos usá-lo para qualquer animação. Eu não recusaria isso por criar infinitas animações fechadas. No entanto, falaremos sobre isso no próximo artigo.

Para ver melhor como a mudança ocorre, substituí nossa visualização de teste por quadrados elementares, assinados com números e emoldurados.

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


E para tornar esse movimento ainda melhor, eu removi .clipped () do 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()
    }
}


A propósito, agora precisamos do SpinTransitionModifier em nosso próprio modificador. Ele foi criado apenas para combinar os dois modificadores, rotationEffect e clipped () em um, para que a animação de rotação não ultrapasse o escopo selecionado para a nossa Visualização. Agora, podemos usar .rotationEffect () diretamente dentro de .modifier (), não precisamos de um intermediário na forma de SpinTransitionModifier.

Quando a vista morre


Um ponto interessante é o ciclo de vida da exibição, se for colocado em um if-else. A exibição, embora iniciada e registrada como um elemento da matriz, não é armazenada na memória. Toda elaEstadoos parâmetros são redefinidos para o padrão na próxima vez que aparecerem na tela. Isso é quase o mesmo que inicialização. Apesar do fato de que a própria estrutura de objetos ainda existe, a renderização a removeu de seu campo de visão, pois não existe. Por um lado, isso reduz o uso de memória. Se você tiver um grande número de visualizações complexas na matriz, a renderização precisará desenhá-las todas constantemente, reagindo a alterações - isso afetou negativamente o desempenho. Se não me engano, esse foi o caso antes da atualização do Xcode 11.3. Agora, visualizações inativas são descarregadas da memória de renderização.

Por outro lado, devemos mover todos os estados importantes além do escopo desta visão. Para isso, é melhor usar as variáveis ​​@EnvironmentObject.

Retornando ao ciclo de vida, também deve ser observado que o modificador .onAppear {}, se houver um registrado nesta Visualização, funciona imediatamente após alterar a condição e a aparência da Visualização na tela, mesmo antes do início da animação. Assim, onDisappear {} é acionado após o final da animação de desaparecimento. Lembre-se disso se planeja usá-los com animação de transição.

Qual é o próximo?


Ufa Foi bastante volumoso, mas em detalhes e, espero, inteligível. Honestamente, eu esperava falar sobre animação arco-íris como parte de um artigo, mas não conseguia parar no tempo com os detalhes. Então aguarde a continuação.

A seguinte parte nos espera:

  • uso de gradientes: linear, circular e angular - tudo será útil
  • Cor não é cor: escolha sabiamente.
  • animação em loop: como iniciar e como parar e como parar imediatamente (sem animação, alterando a animação - sim, também existe uma)
  • animação atual do fluxo: prioridades, substituições, animação diferente para objetos diferentes
  • detalhes sobre os tempos de animação: orientaremos os tempos na cauda e na crina, até nossa própria implementação do timingCurve (oh, mantenha-me sete :))
  • como descobrir o momento atual da animação reproduzida
  • Se o SwiftUI não for suficiente

Vou falar sobre tudo isso em detalhes usando o exemplo da criação de animação em arco-íris, como na figura:


Eu não segui o caminho mais fácil, mas colecionei todos os ancinhos que pude alcançar, incorporando esta animação nos princípios descritos acima. A história sobre isso deve ser muito informativa e rica em truques e todos os tipos de hacks, sobre os quais houve poucos relatórios e que serão úteis para aqueles que decidirem se tornar pioneiros no SwiftUI. Aparecerá aproximadamente em uma semana ou duas. A propósito, você pode se inscrever para não perder. Mas isso, é claro, somente se o material lhe parecer útil e o método de apresentação for aprovado. Em seguida, sua assinatura ajudará a trazer rapidamente novos artigos para o topo, levando-os a um público mais amplo mais cedo. Caso contrário, escreva nos comentários o que está errado.

All Articles