Search code examples
swiftanimationswiftuiview

Swiftui dynamic transition on view insertion/removal


I have a use case with a vertical menu with items: link_target1, ..., link_target5.
And I have a container that display a View associated with one of those items.
When I click on one of the menu items, I set a state and display the matching view according to this state.
What i'm trying to achieve, is a dynamic sliding effect on views transition:
If I go from target3 to target1 for example, i'm moving upward. So both views should slide to the bottom.
target3 view disappears by sliding down and target1 view appears by sliding down also.
So it's like all views are vertically stacked and by clicking to the menu I slide from one to another.
I need to be able to control, at the same time, the transition of the discarded and presented view and I'm only able to do that when the user actually click on the menu (cause otherwise, I don't know if we are going up or down).

The code looks like this:

enum ViewScreen: String, CaseIterable, Identifiable {
    case target1
    case target2
    case target3
    case target4
    case target5
        
    var id: String { return self.rawValue }
    var index: Int { ViewScreen.allCases.firstIndex(of: self) ?? 0 }
}

struct ContentView: View {
    @State var target: ViewScreen = .target1 {
        willSet {
            self.previousTarget = target
        }
    }
    @State var previousTarget: ViewScreen = .target1
    var direction: AnyTransition {
        get {
            previousTarget.index <= target.index ? .move(edge: .top) : .move(edge: .bottom)
        }
    }
    
    var body: some View {
        HStack(alignment: .top) {
            SidebarView(target: $target) // It just set target to the clicked link           
            Group {
                switch target {
                    case .target1: View1()
                    case .target2: View2()
                    case .target3: View3()
                    case .target4: View4()
                    case .target5: View5()
                }
            }.transition(AnyTransition.opacity.combined(with: direction))
        }
    }
}

Is it even possible with transitions? Any help is welcome. Thanks guys!


Solution

  • You cannot use willSet here because it doesn't actually get called. You are passing a binding to the sidebar, and the sidebar sets the binding's wrappedValue. willSet only gets called when you directly do target = ... in ContentView.

    I would have a @State storing the transition to use. Change that state when target changes.

    @State var target: ViewScreen = .target1
    @State var direction: AnyTransition = .asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
    
    ...
    
    .onChange(of: target) { oldValue, newValue in
        if oldValue.index > newValue.index {
            direction = .asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom))
        } else {
            direction = .asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top))
        }
    }
    

    Notice that the transition is asymmetric. A transition like .move(edge: .top) moves the view from top to bottom when the view appears, but moves the view from bottom to top when it disappears. IMO, .move(towards:) is a more suitable name than .move(edge:).

    Alternatively, you can replace

    .asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom))
    

    with .push(from: .top), and the opposite case with .push(from: .bottom). Unlike .move which always moves the view towards the same edge, push always moves the view in the same direction. .push also has opacity built-in, so you don't need to combine it with .opacity.

    Now we have the situation from this question, where the transition would not update correctly when changing directions. The target that controls which view to show, needs to be updated just a little later than direction.

    Here is an minimal example:

    @State var pickerTarget: ViewScreen = .target1
    @State var target: ViewScreen = .target1
    @State var direction: AnyTransition = .push(from: .bottom)
    
    var body: some View {
        HStack(alignment: .top) {
            Picker("Pick", selection: $pickerTarget) {
                ForEach(ViewScreen.allCases, id: \.self) {
                    Text($0.rawValue)
                }
            }
            Group {
                switch target {
                    case .target1: Text("1")
                    case .target2: Text("2")
                    case .target3: Text("3")
                    case .target4: Text("4")
                    case .target5: Text("5")
                }
            }
            .transition(direction)
        }
        .onChange(of: pickerTarget) { oldValue, newValue in
            if oldValue.index > newValue.index {
                direction = .push(from: .top)
            } else {
                direction = .push(from: .bottom)
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {
                withAnimation(.linear(duration: 2)) {
                    target = newValue
                }
            }
        }
        
    }