SwiftUI Animations: Why is the container view moving? I'm struggling to understand this behavior
I'm building a simple rotating circle loading animation. I've got the circle rotating but the container view is also moving downward for the duration of the animation. I can't figure out what could...
stackoverflow.com
이것도 이전 포스트와 같은 프로젝트라 네 달 전에 해결한 문제였다. 팀원이 원인을 모르겠다고 같이 해결하려고 보내준 문제였는데 나도 원인을 바로 알지 못했었다. 너무너무 궁금하고 해결하고 싶어서 고군분투했던거라 기억에 남음.
문제는 위 스택오버플로우의 질문자와 같은 상황이었다.
애니메이션이 한 사이클을 마치면 원의 중심부가 살짝 아래로 내려와서 다시 애니메이션을 시작하는 것을 확인할 수 있다. animation이 실행되면서 점점 위쪽으로 이동하는 것이다.
Circle()
.scaleEffect(circleSizeToggle ? 1.2 : 0)
.foregroundColor(.white)
.opacity(circleOpacityToggle ? 0 : 0.3)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 1)
.scaleEffect(circleSizeToggle ? 1.2 : 0)
.opacity(circleOpacityToggle ? 0 : 1)
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + delayTime) {
circleOpacityToggle = true
circleSizeToggle = true
}
}
// animation
.animation(Animation.easeOut(duration: 3).repeatForever(autoreverses: false))
저 뷰 안의 원 하나는 위와 같은 코드로 작성되어 있고, 다섯개의 써클이 겹쳐서 시간차이로 scaleEffect가 적용된다. 이 문제에서 중요한 코드는 onAppear()와 animation()이다. 위 gif처럼 써클 다섯개가 겹쳐져있는 뷰 자체에 border를 줘보면 움직이지 않는데 각각의 써클뷰에 border를 주면 스택오버플로우 질문글처럼 움직이고 있을거다.
이걸 뭐라고 검색할지도 몰라서 더 헤맸다. 질문해주었던 팀원은 네비게이션 뷰로 전환하면서 navigation bar의 렌더링 시기를 의심했다. 실제로 navigation없이 이 뷰를 띄웠을 때는 움직이지 않고 정상적으로 동작했다. 이 부분에서 힌트를 얻어 navigation view를 키워드로 검색하면서 비슷한 질문들을 찾을 수 있었다. 이 문제의 원인은 스레드, animation() vs withAnimation, NavigationView, onAppear의 호출 시기 등이 연관되어 있다.
SwiftUI: Animation Inside NavigationView
I am trying to create a simple animation in SwiftUI. It is basically a rectangle that changes its frame, while staying in the center of the parent view. struct ContentView: View { var body: som...
stackoverflow.com
위 답변을 토대로 파악하기로는 NavigationView의 프레임이 정해지기 전에 onAppear와 animation()이 호출된다. 그래서 view의 프레임이 0일때부터 정해진 사이즈로 변하는동안 animation도 적용되기 때문에 문제가 발생한 것이다. 이 문제의 원인은 withAnimation과 animation의 차이로 귀결된다. 나는 이때 animation implicit과 explicit으로 구분될 수 있다는 것을 처음 알았다.
코드에서 나의 의도는 "뷰 자체에 animation을 걸어놨기 때문에 State 프로퍼티인 circleOpacityToggle, circleSizeToggle의 값이 변하면 애니메이션이 동작해" 라고 암묵적으로 표시한 것이다. 그런데 animation modifier는 해당 뷰 자체에 애니메이션을 적용하기 때문에 네비게이션 뷰의 프레임 사이즈가 0에서 적정값으로 변하는 데까지도 애니메이션이 함께 적용된 듯 하다. 아래 코드와 gif를 보면 이 말을 정확히 화면으로 이해할 수 있다.
Circle()
.foregroundColor(.white)
.animation(.easeIn.repeatForever())
그럼 withAnimation은 뭘까? animation()이 implicit한 애니메이션이라고 했으니 withAnimation은 explicit animation일 것이다. 뭔소리냐면, 애니메이션이 언제 동작할지 명시적으로 표시해줄 수 있다는 것이다. withAnimation이 호출된 블록 내에서만 애니메이션 효과가 발생하고 외부에서는 아무런 영향을 미치지 않는다. 이 방법으로 수정된 코드는 아래와 같다.
Circle()
.scaleEffect(circleSizeToggle ? 1.2 : 0)
.foregroundColor(.white)
.opacity(circleOpacityToggle ? 0 : 0.3)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 1)
.scaleEffect(circleSizeToggle ? 1.2 : 0)
.opacity(circleOpacityToggle ? 0 : 1)
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + delayTime) {
// withAnimation
withAnimation(.easeOut(duration: 3).repeatForever(autoreverses: false)) {
circleOpacityToggle = true
circleSizeToggle = true
}
}
}
중요한 코드는 onAppear()와 그 내부의 withAnimation이다. 이전 코드에서 사용한 animation()이 삭제되고, onAppear 내부에서 withAnimation을 정의해 뷰가 다 구성되고 화면에 나타날 때 애니메이션이 실행되어야 함을 명시하고 있다. circleOpacityToggle과 circleSizeToggle의 상태 변화는 애니메이션 블록 내에 정의하여 애니메이션이 실행될 때 값을 변경하도록 했다. 이렇게 하면 결과적으로 애니메이션이 뷰 사이즈가 모두 정해진 뒤 화면에 onAppear될 때 실행되기 때문에 고정된 위치에서 잘 동작하여 문제가 해결된다.
해결하고 보면 단 한 줄의 코드가 문제였지만, explicit animation과 implicit animation의 차이를 모르면 어리둥절할 수 있다. 그리고 Navigation View의 렌더링 방식도 궁금해지는 참이다. 두번째 스택오버플로우 질문에서 가장 추천을 많이 받은 답변을 보면 네비게이션 뷰의 사이즈가 0에서부터 일정 프레임값으로 정해진다고 한다. animation()이 함께 적용되었던 것을 보면 그런 것 같다.
사용하는 방식에 따라 explicit, implicit이라는 키워드를 사람들이 함께 사용하는 듯 하다. 해결하는데 두시간정도 걸렸던 것 같은데 공식문서에서 설명하는 내용이 아니다보니 원인조차 알아내기 어려웠지만 흥미로운 개념이었다. 코드는 역시 쓰면 쓸수록 깊이 이해할 수 있는 지점이 생기는 것 같다. 즐겁다
끗 -