victorgson
victorgson

Reputation: 191

Conditionally setting two different transitions on the same View

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.

gif

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

Answers (2)

Benzy Neez
Benzy Neez

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

Sweeper
Sweeper

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

Related Questions