Nested transitions / animations in SwiftUI - swift

The goal is to create a view on top on another view, that has elements and changes color while moving away to the top of the screen. So we have one variable / state that triggers an animation where both views have sub transitions / animations.
Problem is: there is either no movement or no colortransition at some point in the animation / transition because adding a transition somehow overwrites all the individual transitions of the elements in the stack.
EDIT: Due to simplicity I rearranged my code avoiding the described behaviour. But it seems like a really bad solution (repeating lots of code, if's ...) Has someone an idea how to write the following working example in a more compact/advanced way?
Working, but probably unnecessary complicated:
struct ContentView: View {
#State var signUpLoginView_active: Bool = true
var body: some View {
ZStack{
Color.orange
if signUpLoginView_active {
ZStack {
Color.white
}
.transition(.move(edge: .top))
.zIndex(1)
}
if signUpLoginView_active {
ZStack {
Color.red
}
.transition(AnyTransition.move(edge: .top).combined(with: AnyTransition.opacity))
.zIndex(2)
}
Button("test"){
withAnimation(.easeInOut(duration: 2)){
signUpLoginView_active.toggle()
}
}.zIndex(3)
}.ignoresSafeArea()
}
}
My Approach to simplify (not working correctly):
struct ContentView: View {
#State var signUpLoginView_active: Bool = true
var body: some View {
ZStack{
Color.orange
if signUpLoginView_active {
ZStack {
Color.white
Color.red.opacity(signUpLoginView_active ? 1 : 0)
}
.transition(AnyTransition.move(edge: .top))
}
Button("test"){
withAnimation(.easeInOut){
signUpLoginView_active.toggle()
}
}
}.ignoresSafeArea()
}
}

Although it might get more tricky than this simple answer, you can combine transitions together so they take effect seamlessly:
.transition(AnyTransition.move(edge: .top)
.combined(with: AnyTransition.scale))
In an optimal case, you should not need those AnyTransitions:
.transition(.move(edge: .top)
.combined(with: .scale))
but it sometimes results in an Xcode error, which might or might not give any clue of the real problem that Xcode has with your code.
Optionally you might need to define whether your transitions should take effect only on removal or insertion, or ask for different Transitions on each of removal or insertion:
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: .top),
removal: AnyTransition.move(edge: .bottom))
.combined(with: AnyTransition.asymmetric(insertion: .scale,
removal: .identity)))
// `.identity` means no transition

Related

Reverse animation for NavigationLink?

I am currently trying to implement a back button in my app. I have some Views linked through NavigationLinks and I'm using the default animation. I am pretty sure the default is .easeIn but tbh I can't really tell the difference between them. I tried making it .easeOut bc I assumed it would go the opposite way but it doesn't. I want my back button to essentially have the reverse direction of the normal animation to move forward in the app, but I can't seem to find a good answer in the documentation. Is there a good way to just reverse the default animation so it looks like it's going backwards?
I have mimicked the NavigationLink behaviour in one of my apps, because I needed more control on the behaviours and UI elements.
Basically, I have given a transition to the List view and an opposite transition to the detail view. If you add some "chevron" system images and place the buttons in the right position, a good eye can catch the trick, but I think it works pretty good for the average user.
Here is the code, check out the .transition() in each view (just remember to use withAnimation() when changing the state):
struct Example: View {
#State private var detail: String? = nil
let details = ["First", "Second", "Third"]
var body: some View {
VStack {
if detail == nil {
List(details, id: \.self) { item in
Text("Tap to see the detail \(item)")
.onTapGesture {
withAnimation {
self.detail = item
}
}
}
.transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)))
} else {
VStack {
Button {
withAnimation {
detail = nil
}
} label: {
Text("Back")
}
Text("Now you see only \(detail ?? "not found")")
.padding()
}
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
}
}
}

Can you control the progress of a SwiftUI animation

Is it possible to control the progress of withAnimation
Ideal API would look something like this
withAnimation(progress: percentDragged) { showSecondView.toggle() }
Here's a full code example of the above line in context.
The goal is to smoothly slide between different app states with a user controlled gesture.
struct ContentView: View {
#State var showSecondView = false
var body: some View {
VStack {
ZStack {
if !showSecondView {
Text("First view")
.padding()
.background(Color.blue)
.transition(.slide)
} else {
Text("Second view")
.padding()
.background(Color.red)
.transition(.slide)
}
}
}
.gesture(
DragGesture()
.onChanged { value in
let percentDragged = value.translation.width / UIScreen.main.bounds.width
// Here I want to gradually slide (interpolate) between the two states
// Ideal API would look like this:
withAnimation(progress: percentDragged) { showSecondView.toggle() }
}
)
}
}
Yes.. I know I can trigger an animation whenever I want (like this). but that's not what I'm looking for! I want to control the progress
if percentDragged > 0.5 {
withAnimation { showSecondView.toggle() }
}
Yes.. I also know I could manually store a #State var offset: Double and control my own slide.
This just gets nasty when my app state is tied to tons of different visual changes. Views appearing/disappearing, alignments shifting, etc.

SwiftUI onTapGesture called only once

I am working on this audio player with multiple view components in it.
I added a way to hide/show the top view and the bottom view when we click anywhere in the middle view.
Before it was working fine, but recently when I tried again, it only dismiss it and doesn't trigger the onTapGesture again.
I believe the only difference with before is that the view is presented instead of pushed in a view controller.
I have tried to use a custom gesture with a TapGesture() on onEnded() but the same result.
I also tried to add a Rectangle shape like said [here][1].
struct PlayerView: View {
#ObservedObject private var playerState = PlayerState()
#Binding var isPlayerReduced: Bool
private let interfaceColor: Color = .gray//.black
private let interfaceOpacity: Double = 0.9
private let interfaceAnimationDuration: Double = 0.4
var body: some View {
ZStack(content: {
GeometryReader(content: { geometry in
VStack(content: {
if !self.playerState.isInterfaceHidden {
TopPlayerView(playerState: self.playerState,
isPlayerReduced: self.$isPlayerReduced)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
MiddlePlayerView(skipIntro: self.$playerState.skipIntro)
// Allow to spread the background zone for click purposes
.background(Color.clear)
// I want to have the middle under my TopPlayer and my BottomPlayer
.zIndex(-1)
.onTapGesture(perform: {
withAnimation(.easeInOut(duration: self.interfaceAnimationDuration)) {
self.playerState.isInterfaceHidden.toggle()
}
})
// .gesture(TapGesture()
// .onEnded({ _ in
// }))
if !self.playerState.isInterfaceHidden {
BottomPlayerView(playerState: self.playerState)
.padding(.bottom, geometry.safeAreaInsets.bottom)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
})
})
})
.background(Color.black)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
I am kind of out of ideas here, any help is welcomed! thank you!
Alright, so after touching everything possible in this code. I ended up making it work.
The difference is where I put the padding to my views.
I switch the paddings to the VStack instead of my views in the VStack.
It seems to work now.
I post below the working code.
var body: some View {
ZStack(alignment: .center, content: {
GeometryReader(content: { geometry in
VStack(content: {
self.topMarker()
if !self.playerState.isInterfaceHidden {
TopPlayerView(playerState: self.playerState,
isPlayerReduced: self.$isPlayerReduced)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
MiddlePlayerView(skipIntro: self.$playerState.skipIntro)
// Allow to spread the background zone for click purposes
.background(Color.white.opacity(0.00000001))
// I want to have the middle under my TopPlayer and my BottomPlayer
.zIndex(-1)
.onTapGesture(perform: {
withAnimation(.easeInOut(duration: self.interfaceAnimationDuration)) {
self.playerState.isInterfaceHidden.toggle()
}
})
if !self.playerState.isInterfaceHidden {
BottomPlayerView(playerState: self.playerState)
.transition(.opacity)
.background(self.interfaceColor.opacity(self.interfaceOpacity))
}
})
.padding(.top, 8)
.padding(.bottom, geometry.safeAreaInsets.bottom)
})
})
.background(Color.black)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
}
To be honest, I have no idea what would be the difference here...
Even in the view debugger there is no difference..

SwiftUI View struct without reloading

I would like to create a starry background view in SwiftUI that has its stars located randomly using Double.random(), but does not reinitialise them and move them when the parent view reloads its var body.
struct ContentView: View {
#State private var showButton = true
var body: some View {
ZStack {
BackgroundView()
if showButton {
Button("Tap me"){
self.showButton = false
}
}
}
}
}
I define my background view as such.
struct BackgroundView: View {
var body: some View {
ZStack {
GeometryReader { geometry in
Color.black
ForEach(0..<self.getStarAmount(using: geometry), id: \.self){ _ in
Star(using: geometry)
}
LinearGradient(gradient: Gradient(colors: [.purple, .clear]), startPoint: .bottom, endPoint: .top)
.opacity(0.7)
}
}
}
func getStarAmount(using geometry: GeometryProxy) -> Int {
return Int(geometry.size.width*geometry.size.height/100)
}
}
A Star is defined as
struct Star: View {
let pos: CGPoint
#State private var opacity = Double.random(in: 0.05..<0.4)
init(using geometry: GeometryProxy) {
self.pos = CGPoint(x: Double.random(in: 0..<Double(geometry.size.width)), y: Double.random(in: 0..<Double(geometry.size.height)))
}
var body: some View {
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.onAppear(){
withAnimation(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
}
}
}
}
As one can see, a Star heavily relies on random values, for both its animation (to create a 'random' twinkling effect) as well as its position. When the parent view of the BackgroundView, ContentView in this example, gets redrawn however, all Stars get reinitialised, their position values change and they move across the screen. How can this best be prevented?
I have tried several approaches to prevent the positions from being reinitialised. I can create a struct StarCollection as a static let of BackgroundView, but this is quite cumbersome. What is the best way to go about having a View dependent on random values (positions), only determine those positions once?
Furthermore, the rendering is quite slow. I have attempted to call .drawingGroup() on the ForEach, but this then seems to interfere with the animation's opacity interpolation. Is there any viable way to speed up the creation / re-rendering of a view with many Circle() elements?
The slowness coming out from the overcomplicated animations setting in onAppear, you only need the self.opacity state change to initiate the animation, so please move animation out and add to the shape directly.
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.animation(Animation.linear(duration: 0.2).delay(Double.random(in: 0..<6)).repeatForever())
.onAppear(){
// withAnimation{ //(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
// }
}

Sliding one SwiftUI view out from underneath another

I'm attempting to construct an animation using SwiftUI.
Start: [ A ][ B ][ D ]
End: [ A ][ B ][ C ][ D ]
The key elements of the animation are:
C should appear to slide out from underneath B (not expand from zero width)
The widths of all views are defined by subviews, and are not known
The widths of all subviews should not change during or after the animation (so, total view width is larger when in the end state)
I'm having a very difficult time satisfying all of these requirements with SwiftUI, but have been able to achieve similar affects with auto-layout in the past.
My first attempt was a transition using an HStack with layoutPriorities. This didn't really come close, because it affects the width of C during the animation.
My second attempt was to keep the HStack, but use a transition with asymmetrical move animations. This came really close, but the movement of B and C during the animation does not give the effect that C was directly underneath B.
My latest attempt was to scrap relying on an HStack for the two animating views, and use a ZStack instead. With this setup, I can get my animation perfect by using a combination of offset and padding. However, I can only get it right if I make the frame sizes of B and C known values.
Does anyone have any ideas on how to achieve this effect without requiring fixed frame sizes for B and C?
Since I originally replied to this question, I have been investigating GeometryReader, View Preferences and Anchor Preferences. I have assembled a detailed explanation that elaborates further. You can read it at: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
Once you get the CCCCCCCC view geometry into the textRect variable, the rest is easy. You simply use the .offset(x:) modifier and clipped().
import SwiftUI
struct RectPreferenceKey: PreferenceKey {
static var defaultValue = CGRect()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
typealias Value = CGRect
}
struct ContentView : View {
#State private var textRect = CGRect()
#State private var slideOut = false
var body: some View {
return VStack {
HStack(spacing: 0) {
Text("AAAAAA")
.font(.largeTitle)
.background(Color.yellow)
.zIndex(4)
Text("BBBB")
.font(.largeTitle)
.background(Color.red)
.zIndex(3)
Text("I am a very long text")
.zIndex(2)
.font(.largeTitle)
.background(GeometryGetter())
.background(Color.green)
.offset(x: slideOut ? 0.0 : -textRect.width)
.clipped()
.onPreferenceChange(RectPreferenceKey.self) { self.textRect = $0 }
Text("DDDDDDDDDDDDD").font(.largeTitle)
.zIndex(1)
.background(Color.blue)
.offset(x: slideOut ? 0.0 : -textRect.width)
}.offset(x: slideOut ? 0.0 : +textRect.width / 2.0)
Divider()
Button(action: {
withAnimation(.basic(duration: 1.5)) {
self.slideOut.toggle()
}
}, label: {
Text("Animate Me")
})
}
}
}
struct GeometryGetter: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.fill(Color.clear)
.preference(key: RectPreferenceKey.self, value:geometry.frame(in: .global))
}
}
}
It's hard to tell what exactly you're going for or what's not working. It would be easier to help you if you showed the "wrong" animation you came up with or shared your code.
Anyway, here's a take. I think it sort of does what you specified, though it's certainly not perfect:
Observations:
The animation relies on the assumptions that (A) and (B) together are wider than (C). Otherwise, parts of (C) would appear to the left of A at the start of the animation.
Similarly, the animation relies on the fact that there's no spacing between the views. Otherwise, (C) would be appear to the left of (B) when it's wider than (B).
It may be possible to solve both problems by placing an opaque underlay view in the hierarchy such that it is below (A), (B), and (D), but above (C). But I haven't thought this through.
The HStack seems to expand a tad more quickly than (C) is sliding in, which is why a white portion appears briefly. I didn't manage to eliminate this. I tried adding the same animation(.basic()) modifier to the HStack, the transition, the withAnimation call, and the VStack, but that didn't help.
The code:
import SwiftUI
struct ContentView: View {
#State var thirdViewIsVisible: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 0) {
Text("Lorem ").background(Color.yellow)
.zIndex(1)
Text("ipsum ").background(Color.red)
.zIndex(1)
if thirdViewIsVisible {
Text("dolor sit ").background(Color.green)
.zIndex(0)
.transition(.move(edge: .leading))
}
Text("amet.").background(Color.blue)
.zIndex(1)
}
.border(Color.red, width: 1)
Button(action: { withAnimation { self.thirdViewIsVisible.toggle() } }) {
Text("Animate \(thirdViewIsVisible ? "out" : "in")")
}
}
.padding()
.border(Color.green, width: 1)
}
}