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