Reputation: 191
Good day!
I have two buttons, one with X and one that says Cancel. Depending on which button I press, I want the X button to have a different transition.
When pressing X: I want the removal of the X button to be .opacity.animation(.default)
When pressing Cancel: I want the removal of the X button to be .move(edge: .trailing).combined(with: .opacity).animation(.default)
For context, it's a textField, when I type something the X button appears.
I've tried with a State variable, setting the transition in withAnimation in the Button action. That seems to set the transition for the next cycle/refresh and not the current one. I've also looked into transactions, but I can only manipulate animations from there?
So when I press the X button, the next time I press either X or Cancel it will be what I've set the transition to be. And when I press cancel, the next time it will set transition to be their other one.
As you can see in the GIF, it cycles through the transitions, but I want them to be set for each button.
Expected behaviour:
When pressing X, I want it to fade out using .opacity.
When pressing Cancel, I want the X button to "slide" out, using .move as removal transition.
import Combine
import Foundation
import SwiftUI
struct SearchBar: View {
@Binding var searchField: String
let clearSearchField: () -> Void
let isFocus: Bool
let setFocus: (Bool) -> Void
@State var transition: AnyTransition = .opacity.animation(.default)
var body: some View {
HStack(spacing: 16) {
ZStack {
Color.gray
VStack(alignment: .leading) {
HStack {
TextField(
"Placeholder",
text: $searchField,
onEditingChanged: { _ in setFocus(true) }
)
.foregroundColor(.white)
Spacer()
if searchField.isNonEmpty {
Button {
transition = .opacity.animation(.default)
withAnimation {
clearSearchField()
}
} label: {
Text("X")
}
.transition(
.asymmetric(
insertion: .opacity.animation(.default),
removal: transition
)
)
}
}
}
.padding(16)
}
.cornerRadius(8)
if isFocus {
Button(
action: {
transition = .move(edge: .trailing).combined(with: .opacity)
hideKeyboard()
clearSearchField()
setFocus(false)
},
label: {
Text("Cancel")
.foregroundColor(.white)
}
)
.transition(.move(edge: .trailing).combined(with: .opacity).animation(.default))
}
}
.frame(height: 48)
.foregroundColor(searchField.isEmpty ? .grey: .white)
.padding(16)
.animation(.default, value: [isFocus])
}
}
Is this possible?
Appreciate any help!
Upvotes: 2
Views: 813
Reputation: 20759
One way to solve this is to have multiple layers of containers around the items. The different containers can have different transitions.
This shows it working:
struct ContentView: View {
enum ExitTransitionType {
case opacity
case move
}
@State private var exitType: ExitTransitionType?
var body: some View {
VStack {
HStack {
if exitType != .move {
HStack {
if exitType != .opacity {
HStack {
Spacer()
Button("Cancel") { exitType = .move }
}
.overlay {
Button { exitType = .opacity } label: {
Image(systemName: "xmark")
.resizable()
.scaledToFit()
.padding(12)
.foregroundColor(Color(UIColor.secondaryLabel))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
}
}
.padding(30)
.transition(.opacity)
}
}
.transition(.move(edge: .trailing))
}
}
.frame(minHeight: 100)
if exitType != nil {
Button("Reset") { exitType = nil }
.buttonStyle(.borderedProminent)
.transition(.scale)
}
}
.animation(.easeInOut(duration: 0.8), value: exitType)
}
}
In the case of the opacity transition, the surrounding container (which would have the move transition) is still there but empty. If you wanted to hide this too then you could have an additional boolean flag, say containersAreVisible
, which you set to false in an onDisappear
callback on the nested content.
If you only want part of the content disappearing (say, the X button, but not the Cancel button) then you could move the Cancel button into a container that is one level higher, so that it is included in one transition but excluded from the other.
Ps. For another example of this approach in action, see the solution to SwiftUI bi-directional move transition moving the wrong way in certain cases.
Upvotes: 1
Reputation: 271380
From what I have observed, you cannot change the transition of a view without also changing its identity. Once the view has appeared with a transition, you can't tell it to disappear with another transition.
So one way to work around this is to have two X buttons, one with .opacity
transition, the other with .move
. Use a @State
to decide which button to show:
Example:
@State var showButtons = false
@State var shouldMove = false
var body: some View {
VStack {
// imagine this is your text field
Rectangle().frame(width: 100, height: 100).onTapGesture {
withAnimation {
showButtons = true
}
}
Spacer()
if showButtons {
// make buttons with different transitions
// if shouldMove, show the button with the move transition
// otherwise show the button with the opacity transition
if shouldMove {
makeButton(transition: .asymmetric(insertion: .opacity, removal: .move(edge: .trailing)))
} else {
makeButton(transition: .opacity)
}
Button("Cancel") {
shouldMove = true
withAnimation {
showButtons.toggle()
}
}
}
}.frame(height: 300)
}
@ViewBuilder func makeButton(transition: AnyTransition) -> some View {
Button("X") {
shouldMove = false
withAnimation {
showButtons.toggle()
}
}.transition(transition)
}
Another approach is to give the X button a new id
each time you want to change transitions, hence making a "new" view.
@State var showButtons = false
@State var transition: AnyTransition = .opacity
@State var buttonId = UUID()
var body: some View {
VStack {
Rectangle().frame(width: 100, height: 100).onTapGesture {
withAnimation {
showButtons = true
}
}
Spacer()
if showButtons {
Button("X") {
transition = .opacity
buttonId = UUID() // new id!
withAnimation {
showButtons.toggle()
}
}
.transition(transition)
.id(buttonId)
Button("Cancel") {
transition = .asymmetric(insertion: .opacity, removal: .move(edge: .trailing))
buttonId = UUID() // new id!
withAnimation {
showButtons.toggle()
}
}
}
}.frame(height: 300)
}
Upvotes: 2