iosswiftswiftui

Prevent multiple tap in swiftUI


I'm trying to prevent double taps on all buttons in my SwiftUI app and also reflect the state in the UI by disabling the button while it's blocked. Ideally, I'd like to do this through a Button extension so that I don't have to manually wrap every action or apply .disabled(...) everywhere.

Is there a clean way to extend Button (e.g., via init or a modifier) to wrap the action with a double-tap prevention logic and show the button as disabled during that time — without having to change the usage across the app?

Example of what I want to keep:

Button("Continue") {
    onAction()
}

And have it automatically block rapid taps and show as disabled briefly.


Solution

  • The simplest solution is to override SwiftUI.Button, You can takeover current instances by matching the initializers. the 2 that usually take over most of them are.

    init(action: @escaping @Sendable () async throws -> Void, @ViewBuilder label: @escaping () -> L) {
    
    init(_ titleKey: LocalizedStringKey, action: @escaping @Sendable () async throws -> Void) where L == Text {
    

    Once you have a Basic View you can create your own behaviors.

    import SwiftUI
    
    struct Button<L: View>: View {
        @State private var isProcessing: Bool = false
        let label: () -> L
        let action: @Sendable () async throws -> Void
        
        init(action: @escaping @Sendable () async throws -> Void, @ViewBuilder label: @escaping () -> L) {
            self.label = label
            self.action = action
        }
        
        init(_ titleKey: LocalizedStringKey, action: @escaping @Sendable () async throws -> Void) where L == Text {
            label = {Text(titleKey)}
            self.action = action
        }
    
        //Add more initializers to match SwiftUI.Button as needed.
        
        var body: some View {
            SwiftUI.Button(action: {
                isProcessing.toggle() //trigger task/disable
            }, label: {
                label()
                    .overlay {
                        if isProcessing { //Present a visual for processing
                            ProgressView()
                        }
                    }
            })
            .disabled(isProcessing) // disable button
            .task(id: isProcessing) { // actionable task
                guard isProcessing else {return} // only process if true
                
                do {
                    try await withThrowingTaskGroup { group in
                        //Make the action last at least a half second.
                        group.addTask {
                            try await Task.sleep(for: .seconds(0.5))
                        }
                        group.addTask {
                            try await action()
                        }
                        try await group.waitForAll()
                    }
                } catch {
                    print(error)
                    //Present Alert
                }
                isProcessing = false // re-enable
            }
        }
    }
    
    #Preview {
        VStack {
            Button {
                try await Task.sleep(for: .seconds(5))
    
            } label: {
                Text("Continue")
            }
            
            Button("Continue") {
                try await Task.sleep(for: .seconds(5))
                
            }
        }
    }