- Published on
Building a Floating Action Button (FAB) that Respects Keyboard in SwiftUI
- Authors

- Name
- Mick MacCallum
- @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.
Continue Learning
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
Aligning Text in SwiftUI
Learn how to align single and multiline text in SwiftUI.
How to scale text to fit its parent view with SwiftUI
Learn the ways to scale a text view's font size to fit its parent view in SwiftUI.
