Search code examples
iosswiftui

Show View and Then Hide It In 2 Seconds


I am using the following View Modifier in SwiftUI to create a hide modifier. It looks like this:

struct Hide: ViewModifier {
    
    let delay: Double
    @State private var isVisible = true
    
    func body(content: Content) -> some View {
        content
            .opacity(isVisible ? 1 : 0)
            .onAppear {
                
                // Reset the visibility to true and then hide it after the delay
                withAnimation {
                    isVisible = true
                }
                
                DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                    withAnimation {
                        isVisible = false
                    }
                }
            }
    }
}

extension View {
    func hide(in delay: Double) -> some View {
        modifier(Hide(delay: delay))
    }
}

I use it as shown below:

 if !message.isEmpty {
                Text("ERROR")
                    .hide(in: 2.0)
            }

This only works for the first time. I think the isVisible state is not getting reset properly. Any ideas what I am doing wrong? I really don't want to pass any Binding to the Hide modifier.

UPDATE:

Here is the code that triggers the hide view modifier

struct LoginScreenContainer: View {
    
    @State private var message: String = ""
    
    var body: some View {
        NavigationStack {
            Button("Show Message") {
                message = "Error Message"
            }
        }
        .overlay(alignment: .bottom) {
            if !message.isEmpty {
                Text("ERROR")
                    .hide(in: 2.0)
            }
        }
    }
}

Solution

  • You can have the view modifier detect a change in a value, and re-show the view when that changes.

    struct ShowMomentarily<Trigger: Equatable>: ViewModifier {
        
        let duration: Duration
        let trigger: Trigger
        @State private var isVisible = false // initially not visible
        @State private var initial = true
        
        func body(content: Content) -> some View {
            content
                .opacity(isVisible ? 1 : 0)
                .task(id: trigger) {
                    // do not do the task on the first appearance
                    if initial {
                        initial = false
                        return
                    }
                    
                    isVisible = true
                    do {
                        try await Task.sleep(for: duration)
                        isVisible = false
                    } catch {
                        // this means the task has been cancelled
                        // e.g. 'trigger' changes again before the duration ended
                    }
                }
                .animation(.default, value: isVisible)
        }
    }
    
    extension View {
        func show(for delay: Duration, trigger: some Equatable) -> some View {
            modifier(ShowMomentarily(duration: delay, trigger: trigger))
        }
    }
    

    Here is an example usage, using message as the trigger, and the button assigns a randomly generated string so the trigger changes every time.

    struct ContentView: View {
        @State private var message: String = ""
        
        var body: some View {
            NavigationStack {
                Button("Show Message") {
                    message = UUID().uuidString
                }
            }
            .overlay(alignment: .bottom) {
                Text(message)
                    .show(for: .seconds(2), trigger: message)
            }
        }
    }
    

    This "trigger" pattern can be seen in many existing APIs like sensoryFeedback and phaseAnimator.

    Note that I removed the if !message.isEmpty check. This is because controlling visibility using if and opacity at the same time is quite messy, since their animations are controlled differently. It would be much simpler to just have a single trigger.

    If you assign the same string to message a second time, nothing is changed. message is still "Error Message", so SwiftUI does not update anything. If you need to support this case, you can use a different @State as the trigger.

    struct ContentView: View {
        @State private var message: String = ""
        @State private var trigger = false
        
        var body: some View {
            NavigationStack {
                Button("Show Message") {
                    message = UUID().uuidString
                    trigger.toggle()
                }
            }
            .overlay(alignment: .bottom) {
                Text(message)
                    .show(for: .seconds(2), trigger: trigger)
            }
        }
    }