Swift and SwiftUI tutorials for Swift Developers

How to enable and disable buttons in SwiftUI

In modern iOS application development, the User Interface (UI) isn’t just a collection of static elements; it’s a dynamic conversation with the user. One of the most critical elements of this conversation is the button.

A button shouldn’t just be “clickable.” It must communicate context. Can the user proceed? Is data missing from the form? Is a request being processed? In the old days of UIKit (imperative development), enabling and disabling buttons required manipulating direct references and scattering logic across your view controller. In SwiftUI, thanks to its declarative nature, this process is much more intuitive, though it requires a shift in mindset.

In this comprehensive tutorial, we will explore not only how to use the basic .disabled() modifier to enable and disable buttons in SwiftUI, but also how to orchestrate validation logic, improve User Experience (UX) with visual feedback, and architect your code using patterns like MVVM for maintainability and scalability.


1. The Paradigm Shift: Declarative vs. Imperative

Before writing code, it is vital to understand the philosophy behind SwiftUI.

In the past (UIKit), you were responsible for changing the button.

“If the text field changes, check the length. If it is greater than 5, find the ‘Submit’ button and set its isEnabledproperty to true.”

In SwiftUI, you describe the state of the button.

“The button is disabled if the text length is less than 5.”

The difference is subtle but powerful: The interface is a function of state.


2. Basic Implementation

The simplest way to control a button’s interactivity is via the .disabled(_:) modifier. This modifier takes a Boolean value: if true, the button stops responding to touches and, by default, SwiftUI reduces its opacity to visually indicate its state.

Example: The Simple Toggle

Let’s imagine a scenario where we have a master “Toggle” (switch) that controls whether an action button can be pressed.

import SwiftUI

struct BasicButtonExample: View {
    // 1. State that controls the logic
    @State private var isLocked: Bool = true

    var body: some View {
        VStack(spacing: 20) {
            // Control to change the state
            Toggle("Lock Button", isOn: $isLocked)
                .padding()

            // 2. The Button
            Button(action: {
                print("Action executed!")
            }) {
                Text("Execute Action")
                    .font(.headline)
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            // 3. The Magic Modifier
            .disabled(isLocked)
            // Optional: Manually change opacity if you want more control
            .opacity(isLocked ? 0.5 : 1.0) 
            .padding()
        }
    }
}

Code Analysis:

  • @State: This is the Source of Truth. When isLocked changes, SwiftUI automatically redraws the view.
  • .disabled(isLocked): If isLocked is true, the button is disabled. The action closure will never execute, protecting your business logic.

3. Real World Use Cases: Form Validation

We rarely disable a button with a manual switch. The most common scenario is that the button’s state depends on the integrity of the data entered by the user.

Let’s create a simple “Login” screen. The “Log In” button should only be active if:

  1. The username field is not empty.
  2. The password has at least 6 characters.

Logic in the View (Initial Approach)

For very small applications, we can use computed properties directly within the View.

struct LoginView: View {
    @State private var username = ""
    @State private var password = ""

    // Computed property for validation
    var isFormValid: Bool {
        return !username.isEmpty && password.count >= 6
    }

    var body: some View {
        VStack(spacing: 20) {
            TextField("Username", text: $username)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .autocapitalization(.none)

            SecureField("Password", text: $password)
                .textFieldStyle(RoundedBorderTextFieldStyle())

            Button("Log In") {
                performLogin()
            }
            .buttonStyle(.borderedProminent)
            // We disable if the form is NOT valid
            .disabled(!isFormValid)
        }
        .padding()
    }

    func performLogin() {
        print("Logging in \(username)...")
    }
}

Why is this better than UIKit?

In UIKit, you would have to subscribe to text change events for both fields and call a validation function every time. Here, we simply define isFormValid, and SwiftUI takes care of recalculating it whenever username or password changes.


4. Design and UX: Visual Communication

A disabled button that looks exactly the same as an active one is a serious design error (a Dark Pattern). The user will press the button in frustration, thinking the app is broken.

Although .disabled() applies a default opacity, we often want more granular control over the design.

Customization with ButtonStyle

The cleanest way to handle styles in SwiftUI is by creating a custom ButtonStyle. This allows us to encapsulate visual logic based on whether the button is pressed or disabled.

struct PrimaryButtonStyle: ButtonStyle {
    // Environment variable to know if it is enabled
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .frame(maxWidth: .infinity)
            // Conditional Color
            .background(isEnabled ? Color.blue : Color.gray.opacity(0.3))
            .foregroundColor(isEnabled ? .white : .gray)
            .cornerRadius(8)
            // Scale effect on press (only if enabled)
            .scaleEffect(configuration.isPressed ? 0.98 : 1.0)
            .animation(.easeInOut(duration: 0.2), value: isEnabled)
    }
}

// Usage:
Button("Submit") { ... }
    .buttonStyle(PrimaryButtonStyle())
    .disabled(text.isEmpty)

Key Note: Using @Environment(\.isEnabled) inside the style is the secret to making the style automatically react to the .disabled() modifier applied in the parent view.


5. Professional Architecture: MVVM (Model-View-ViewModel)

When validation logic becomes complex (validating emails with RegEx, checking password matches, accepting terms and conditions), code inside the View becomes messy (“Spaghetti Code”).

This is where MVVM comes in. We move the state and validation logic to a separate class.

Step 1: The ViewModel

We create a class that conforms to ObservableObject. This class will be the “owner” of the form data.

import Combine

class RegistrationViewModel: ObservableObject {
    // User Inputs
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    @Published var agreedToTerms = false

    // Validation Logic
    var isValid: Bool {
        // 1. Basic Email check
        guard email.contains("@") && email.contains(".") else { return false }
        
        // 2. Secure Password
        guard password.count >= 8 else { return false }
        
        // 3. Matching Passwords
        guard password == confirmPassword else { return false }
        
        // 4. Terms
        guard agreedToTerms else { return false }
        
        return true
    }
}

Step 2: The Clean View

Now our view is extremely simple and only concerns itself with displaying data.

struct RegistrationView: View {
    // Inject the ViewModel
    @StateObject private var viewModel = RegistrationViewModel()

    var body: some View {
        Form {
            Section(header: Text("Account")) {
                TextField("Email", text: $viewModel.email)
                SecureField("Password", text: $viewModel.password)
                SecureField("Confirm Password", text: $viewModel.confirmPassword)
            }

            Section {
                Toggle("I accept the terms", isOn: $viewModel.agreedToTerms)
            }

            Section {
                Button(action: {
                    // Submission logic
                    print("Registering...")
                }) {
                    Text("Create Account")
                }
                // The view simply reads 'viewModel.isValid'
                .disabled(!viewModel.isValid)
            }
        }
    }
}

This approach makes your code testable. You can write Unit Tests for RegistrationViewModel without needing to load the graphical interface.


6. Taking it to the Next Level: Haptic Feedback and Errors

Sometimes, simply disabling the button isn’t enough. The user might wonder: “Why can’t I press this?”

A modern UX trend is to leave the button enabled, but if the data is invalid, show an error or a “shake” animation when pressed.

Example: Button with Validation on Tap

struct InteractiveValidationButton: View {
    @State private var text = ""
    @State private var attempts: Int = 0 // To trigger animation
    @State private var showError = false

    var body: some View {
        VStack {
            TextField("Enter 'SwiftUI'", text: $text)
                .textFieldStyle(.roundedBorder)
                .padding()
                .border(showError ? Color.red : Color.clear)

            if showError {
                Text("Text must be 'SwiftUI'")
                    .foregroundColor(.red)
                    .font(.caption)
            }

            Button("Verify") {
                if text == "SwiftUI" {
                    print("Success")
                    showError = false
                } else {
                    // Error Feedback
                    withAnimation(.default) {
                        attempts += 1
                        showError = true
                    }
                }
            }
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(8)
            // Shake Animation
            .modifier(ShakeEffect(animatableData: CGFloat(attempts)))
        }
    }
}

// Geometric Modifier for Shake Effect (simplified)
struct ShakeEffect: GeometryEffect {
    var amount: CGFloat = 10
    var shakesPerUnit = 3
    var animatableData: CGFloat

    func effectValue(size: CGSize) -> ProjectionTransform {
        ProjectionTransform(CGAffineTransform(translationX:
            amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
            y: 0))
    }
}

Note: This approach is different from disabling the button. Instead of preventing the action, you allow the action but handle the error. Choose the approach based on the context of your app.


7. Accessibility (A11y): The Forgotten Factor

When you visually disable a button, you must also ensure that users employing assistive technologies (like VoiceOver) understand what is happening.

Fortunately, SwiftUI’s .disabled() modifier handles this quite well by default. VoiceOver will announce the button as “Dimmed” or “Unavailable.”

However, for a first-class experience, consider explaining why it is disabled if the user tries to interact with it, or use .accessibilityHint() on text fields to indicate requirements.

Button("Log In") { ... }
    .disabled(!isValid)
    .accessibilityLabel("Log In Button")
    .accessibilityValue(!isValid ? "Disabled, check fields" : "Enabled")

8. Common Mistakes and Solutions

Error A: The Order of Modifiers

In SwiftUI, order matters. If you apply a .gesture (like a tap) after the .disabled(), sometimes the gesture might still be captured. Golden Rule: Place .disabled() at the end of the button’s modifier chain, but before any positioning modifiers (padding, etc.) if you want the padding to also be “non-interactive” (though this is less relevant for standard buttons).

Error B: Complex Logic in the body

Avoid putting complex if statements directly inside the .disabled() modifier.

  • Bad: .disabled(email.count > 5 && password.count > 3 || !terms)
  • Good: .disabled(!isFormValid) (using a computed property). This vastly improves readability.

Error C: Forgetting Color Feedback

If you use a totally custom button style (using .background(Color.red) directly on the label), the button won’t automatically turn gray when disabled. You must handle the color manually as we saw in the ButtonStyle section.


Conclusion

Enabling and disabling buttons in SwiftUI and Xcode is a gateway to understanding reactive state management. We have moved from simply writing .disabled(true) to building a robust MVVM architecture that separates logic from the view and improves user experience.

Summary of Key Points:

  1. Use .disabled(_:) for basic control.
  2. Use @State and computed properties for simple logic.
  3. Adopt MVVM and ObservableObject for complex forms.
  4. Implement ButtonStyle to adapt visual design to state (active/inactive).
  5. Never forget Accessibility.

State control is what differentiates an application that “works” from one that “feels right.” By mastering these concepts, you are one step closer to being an expert iOS developer.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

SwiftUI Toolbar - Tutorial with Examples

Next Article

How to use iPhone Dynamic Island in SwiftUI

Related Posts