avatar
Published on

Building a Floating Action Button (FAB) that Respects Keyboard in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

Floating Action Buttons (FABs) are a Material Design pattern popularized by Android, but they work beautifully in iOS apps too. The challenge in SwiftUI is making them keyboard-aware: when users tap a text field and the keyboard appears, your FAB needs to move up so it doesn't cover the keyboard or get hidden behind it.

We'll build a reusable FAB component that automatically repositions itself when the keyboard appears, using keyboard notifications and safe area adjustments.

The Challenge

A naive FAB implementation uses .overlay() or .ZStack with bottom alignment. But when the keyboard appears:

  • The FAB gets hidden behind the keyboard
  • Or it covers the keyboard's accessory bar
  • Or it blocks text fields near the bottom

The solution requires monitoring keyboard events and adjusting the FAB's position dynamically.

Basic Floating Action Button

Start with a simple FAB:

import SwiftUI

struct FloatingActionButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: "plus")
                .font(.title2)
                .fontWeight(.semibold)
                .foregroundColor(.white)
                .frame(width: 60, height: 60)
                .background(Color.blue)
                .clipShape(Circle())
                .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
        }
    }
}

Keyboard Observer

Create an observable object to track keyboard state:

import SwiftUI
import Combine

class KeyboardObserver: ObservableObject {
    @Published var keyboardHeight: CGFloat = 0
    @Published var isKeyboardVisible = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        // Keyboard will show
        NotificationCenter.default
            .publisher(for: UIResponder.keyboardWillShowNotification)
            .compactMap { notification in
                notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
            }
            .map { $0.height }
            .sink { [weak self] height in
                self?.keyboardHeight = height
                self?.isKeyboardVisible = true
            }
            .store(in: &cancellables)

        // Keyboard will hide
        NotificationCenter.default
            .publisher(for: UIResponder.keyboardWillHideNotification)
            .sink { [weak self] _ in
                self?.keyboardHeight = 0
                self?.isKeyboardVisible = false
            }
            .store(in: &cancellables)
    }
}

Keyboard-Aware FAB

Integrate the keyboard observer with the FAB:

struct KeyboardAwareFAB<Label: View>: View {
    @StateObject private var keyboard = KeyboardObserver()

    let action: () -> Void
    let label: Label

    init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) {
        self.action = action
        self.label = label()
    }

    var body: some View {
        VStack {
            Spacer()

            HStack {
                Spacer()

                Button(action: action) {
                    label
                        .frame(width: 60, height: 60)
                        .background(Color.blue)
                        .clipShape(Circle())
                        .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                }
                .padding(.trailing, 20)
                .padding(.bottom, keyboard.isKeyboardVisible ? keyboard.keyboardHeight + 20 : 20)
            }
        }
        .animation(.easeOut(duration: 0.25), value: keyboard.keyboardHeight)
    }
}

Usage Example

Use the keyboard-aware FAB in your view:

struct MessageView: View {
    @State private var message = ""

    var body: some View {
        ZStack {
            VStack {
                ScrollView {
                    // Your content
                    VStack(spacing: 16) {
                        ForEach(0..<20, id: \.self) { index in
                            Text("Message \(index)")
                                .padding()
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .background(Color.gray.opacity(0.1))
                                .cornerRadius(8)
                        }
                    }
                    .padding()
                }

                // Text field at bottom
                HStack {
                    TextField("Type a message...", text: $message)
                        .textFieldStyle(.roundedBorder)

                    Button("Send") {
                        sendMessage()
                    }
                }
                .padding()
                .background(Color.white)
            }

            // Floating action button
            KeyboardAwareFAB(action: addAttachment) {
                Image(systemName: "paperclip")
                    .font(.title2)
                    .foregroundColor(.white)
            }
        }
    }

    func sendMessage() {
        print("Sending: \(message)")
        message = ""
    }

    func addAttachment() {
        print("Add attachment tapped")
    }
}

Alternative: Using GeometryReader

For more precise control, use GeometryReader with safe area:

struct SafeAreaFAB<Label: View>: View {
    @StateObject private var keyboard = KeyboardObserver()

    let action: () -> Void
    let label: Label

    init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) {
        self.action = action
        self.label = label()
    }

    var body: some View {
        GeometryReader { geometry in
            VStack {
                Spacer()

                HStack {
                    Spacer()

                    Button(action: action) {
                        label
                            .frame(width: 60, height: 60)
                            .background(Color.blue)
                            .clipShape(Circle())
                            .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                    }
                    .padding(.trailing, 20)
                }
                .padding(.bottom, bottomPadding(for: geometry))
            }
        }
        .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboard.keyboardHeight)
    }

    func bottomPadding(for geometry: GeometryProxy) -> CGFloat {
        if keyboard.isKeyboardVisible {
            // Position above keyboard
            return keyboard.keyboardHeight - geometry.safeAreaInsets.bottom + 20
        } else {
            // Default bottom padding
            return 20
        }
    }
}

Multiple FABs

Support multiple action buttons:

struct MultiFAB: View {
    @StateObject private var keyboard = KeyboardObserver()
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Spacer()

            HStack {
                Spacer()

                VStack(spacing: 16) {
                    if isExpanded {
                        // Secondary actions
                        FABButton(
                            icon: "photo",
                            color: .green,
                            action: { print("Photo") }
                        )
                        .transition(.scale.combined(with: .opacity))

                        FABButton(
                            icon: "doc",
                            color: .orange,
                            action: { print("Document") }
                        )
                        .transition(.scale.combined(with: .opacity))

                        FABButton(
                            icon: "link",
                            color: .purple,
                            action: { print("Link") }
                        )
                        .transition(.scale.combined(with: .opacity))
                    }

                    // Main FAB
                    Button {
                        withAnimation(.spring()) {
                            isExpanded.toggle()
                        }
                    } label: {
                        Image(systemName: isExpanded ? "xmark" : "plus")
                            .font(.title2)
                            .fontWeight(.semibold)
                            .foregroundColor(.white)
                            .frame(width: 60, height: 60)
                            .background(Color.blue)
                            .clipShape(Circle())
                            .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                            .rotationEffect(.degrees(isExpanded ? 45 : 0))
                    }
                }
                .padding(.trailing, 20)
                .padding(.bottom, keyboard.isKeyboardVisible ? keyboard.keyboardHeight + 20 : 20)
            }
        }
        .animation(.spring(response: 0.3), value: keyboard.keyboardHeight)
    }
}

struct FABButton: View {
    let icon: String
    let color: Color
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: icon)
                .font(.title3)
                .foregroundColor(.white)
                .frame(width: 50, height: 50)
                .background(color)
                .clipShape(Circle())
                .shadow(color: .black.opacity(0.2), radius: 3, x: 0, y: 2)
        }
    }
}

Custom Positioning

Allow customizable positioning:

struct CustomPositionFAB<Label: View>: View {
    @StateObject private var keyboard = KeyboardObserver()

    enum Position {
        case bottomTrailing
        case bottomLeading
        case bottomCenter
    }

    let position: Position
    let horizontalPadding: CGFloat
    let action: () -> Void
    let label: Label

    init(
        position: Position = .bottomTrailing,
        horizontalPadding: CGFloat = 20,
        action: @escaping () -> Void,
        @ViewBuilder label: () -> Label
    ) {
        self.position = position
        self.horizontalPadding = horizontalPadding
        self.action = action
        self.label = label()
    }

    var body: some View {
        VStack {
            Spacer()

            HStack {
                if position == .bottomTrailing {
                    Spacer()
                }

                Button(action: action) {
                    label
                        .frame(width: 60, height: 60)
                        .background(Color.blue)
                        .clipShape(Circle())
                        .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                }
                .padding(.horizontal, horizontalPadding)

                if position == .bottomLeading {
                    Spacer()
                }
            }
            .padding(.bottom, keyboard.isKeyboardVisible ? keyboard.keyboardHeight + 20 : 20)
        }
        .animation(.easeOut(duration: 0.25), value: keyboard.keyboardHeight)
    }
}

// Usage
CustomPositionFAB(position: .bottomLeading, action: {}) {
    Image(systemName: "plus")
        .foregroundColor(.white)
}

Extended FAB (with text)

Create an extended FAB with label:

struct ExtendedFAB: View {
    @StateObject private var keyboard = KeyboardObserver()

    let title: String
    let icon: String
    let action: () -> Void

    var body: some View {
        VStack {
            Spacer()

            HStack {
                Spacer()

                Button(action: action) {
                    HStack(spacing: 12) {
                        Image(systemName: icon)
                            .font(.title3)

                        Text(title)
                            .font(.headline)
                    }
                    .foregroundColor(.white)
                    .padding(.horizontal, 24)
                    .padding(.vertical, 16)
                    .background(Color.blue)
                    .clipShape(Capsule())
                    .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                }
                .padding(.trailing, 20)
                .padding(.bottom, keyboard.isKeyboardVisible ? keyboard.keyboardHeight + 20 : 20)
            }
        }
        .animation(.spring(response: 0.3), value: keyboard.keyboardHeight)
    }
}

// Usage
ExtendedFAB(title: "New Message", icon: "square.and.pencil") {
    print("Compose new message")
}

Handling ScrollView

Ensure FAB doesn't interfere with scrolling:

struct ScrollableFABView: View {
    @State private var items = Array(0..<50)

    var body: some View {
        ZStack {
            ScrollView {
                LazyVStack(spacing: 16) {
                    ForEach(items, id: \.self) { item in
                        Text("Item \(item)")
                            .padding()
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(Color.gray.opacity(0.1))
                            .cornerRadius(8)
                    }
                }
                .padding()
                .padding(.bottom, 80) // Space for FAB
            }

            KeyboardAwareFAB(action: addItem) {
                Image(systemName: "plus")
                    .font(.title2)
                    .foregroundColor(.white)
            }
        }
    }

    func addItem() {
        items.insert(items.count, at: 0)
    }
}

Accessibility

Make your FAB accessible:

struct AccessibleFAB: View {
    @StateObject private var keyboard = KeyboardObserver()

    let action: () -> Void
    let accessibilityLabel: String
    let accessibilityHint: String?

    var body: some View {
        VStack {
            Spacer()

            HStack {
                Spacer()

                Button(action: action) {
                    Image(systemName: "plus")
                        .font(.title2)
                        .foregroundColor(.white)
                        .frame(width: 60, height: 60)
                        .background(Color.blue)
                        .clipShape(Circle())
                        .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 3)
                }
                .accessibilityLabel(accessibilityLabel)
                .accessibilityHint(accessibilityHint ?? "")
                .accessibilityAddTraits(.isButton)
                .padding(.trailing, 20)
                .padding(.bottom, keyboard.isKeyboardVisible ? keyboard.keyboardHeight + 20 : 20)
            }
        }
        .animation(.easeOut(duration: 0.25), value: keyboard.keyboardHeight)
    }
}

// Usage
AccessibleFAB(
    action: { print("Add") },
    accessibilityLabel: "Add new item",
    accessibilityHint: "Double tap to create a new item"
)

Performance Optimization

For better performance with frequent updates:

class OptimizedKeyboardObserver: ObservableObject {
    @Published var bottomOffset: CGFloat = 20

    private var cancellables = Set<AnyCancellable>()
    private let minimumOffset: CGFloat = 20

    init() {
        NotificationCenter.default
            .publisher(for: UIResponder.keyboardWillChangeFrameNotification)
            .compactMap { notification -> (CGFloat, TimeInterval)? in
                guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
                      let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval else {
                    return nil
                }
                return (frame.height, duration)
            }
            .sink { [weak self] height, _ in
                guard let self = self else { return }
                self.bottomOffset = height > 0 ? height + self.minimumOffset : self.minimumOffset
            }
            .store(in: &cancellables)
    }
}

Best Practices

Do:

  • Animate FAB movement to match keyboard animation
  • Keep FAB size standard (56-60pt diameter)
  • Use clear, recognizable icons
  • Position in bottom-trailing corner (standard)
  • Add proper accessibility labels
  • Test with keyboard on various screen sizes

Don't:

  • Block interactive elements with FAB
  • Use multiple FABs unless necessary
  • Forget to handle keyboard dismissal
  • Make FAB too small (< 44pt touch target)
  • Use confusing or abstract icons
  • Forget bottom safe area padding

Testing Checklist

Test your FAB with:

  • Different keyboard types (default, numeric, etc.)
  • Text fields at various scroll positions
  • Keyboard appearing/dismissing quickly
  • Rotating device (landscape/portrait)
  • Different screen sizes (iPhone SE to Pro Max)
  • VoiceOver enabled
  • Reduce motion accessibility setting

A well-implemented FAB adds a convenient, always-accessible action to your SwiftUI app while intelligently staying out of the way when users are typing.