avatar
Published on

Creating an iMessage-Style Input Accessory View in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

Creating an input accessory view that stays above the keyboard is a common requirement for chat applications, note-taking apps, and other interfaces that need persistent input controls. SwiftUI provides a built-in solution using ToolbarItemPlacement.keyboard, but we can also create custom solutions for more complex requirements.

Using SwiftUI's Built-in Keyboard Toolbar

SwiftUI provides a native way to add toolbar items above the keyboard using ToolbarItemPlacement.keyboard. This is the recommended approach for simple cases:

struct ChatView: View {
    @State private var messageText = ""
    @State private var messages: [String] = []
    
    var body: some View {
        NavigationStack {
            VStack {
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 8) {
                        ForEach(messages, id: \.self) { message in
                            Text(message)
                                .padding()
                                .background(Color.blue)
                                .foregroundColor(.white)
                                .cornerRadius(16)
                                .frame(maxWidth: .infinity, alignment: .trailing)
                        }
                    }
                    .padding()
                }
            }
            .navigationTitle("Chat")
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    TextField("Message", text: $messageText)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                    
                    Button("Send") {
                        sendMessage()
                    }
                    .disabled(messageText.isEmpty)
                }
            }
        }
    }
    
    private func sendMessage() {
        guard !messageText.isEmpty else { return }
        messages.append(messageText)
        messageText = ""
    }
}

Custom KeyboardToolbar for Complex Requirements

For more complex input bars with custom styling, multiple buttons, or specific layout requirements, you can create a custom ViewModifier:

struct KeyboardToolbar<ToolbarView: View>: ViewModifier {
    private let height: CGFloat
    private let toolbarView: ToolbarView
    
    init(height: CGFloat, @ViewBuilder toolbar: () -> ToolbarView) {
        self.height = height
        self.toolbarView = toolbar()
    }
    
    func body(content: Content) -> some View {
        ZStack(alignment: .bottom) {
            GeometryReader { geometry in
                VStack {
                    content
                }
                .frame(width: geometry.size.width, height: geometry.size.height - height)
            }
            toolbarView
                .frame(height: self.height)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

View Extension for Easy Usage

To make the custom toolbar easy to use, we can create a view extension:

extension View {
    func keyboardToolbar<ToolbarView>(
        height: CGFloat, 
        @ViewBuilder view: @escaping () -> ToolbarView
    ) -> some View where ToolbarView: View {
        modifier(KeyboardToolbar(height: height, toolbar: view))
    }
}

Creating an iMessage-Style Chat Interface

Here's how to use the custom keyboard toolbar to create a chat interface similar to iMessage:

struct CustomChatView: View {
    @State private var messageText = ""
    @State private var messages: [String] = []

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 8) {
                    ForEach(messages, id: \.self) { message in
                        Text(message)
                            .padding()
                            .background(Color.blue)
                            .foregroundColor(.white)
                            .cornerRadius(16)
                            .frame(maxWidth: .infinity, alignment: .trailing)
                    }
                }
                .padding()
            }
        }
        .keyboardToolbar(height: 60) {
            ChatInputBar(text: $messageText) {
                sendMessage()
            }
        }
    }

    private func sendMessage() {
        guard !messageText.isEmpty else { 
            return
        }

        messages.append(messageText)
        messageText = ""
    }
}

Building the Chat Input Bar

The input bar component provides the iMessage-style interface:

struct ChatInputBar: View {
    @Binding var text: String
    let onSend: () -> Void
    
    var body: some View {
        HStack(spacing: 12) {
            TextField("Message", text: $text, axis: .vertical)
                .padding(.horizontal, 12)
                .padding(.vertical, 7)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(
                            Color(UIColor.separator).opacity(0.5),
                            lineWidth: 1
                        )
                )
                .lineLimit(1...4)
            
            Button(action: onSend) {
                Image(systemName: "arrow.up.circle.fill")
                    .font(.title2)
                    .foregroundColor(.blue)
            }
            .disabled(text.isEmpty)
        }
        .padding(.horizontal)
        .padding(.vertical, 8)
        .background(Color(.systemBackground))
        .overlay(
            Rectangle()
                .frame(height: 0.5)
                .foregroundColor(Color(.separator)),
            alignment: .top
        )
    }
}

Advanced Input Bar with Additional Features

You can extend the input bar to include more features like attachments, emoji picker, or voice recording:

struct AdvancedChatInputBar: View {
    @Binding var text: String
    @State private var showingAttachmentSheet = false
    let onSend: () -> Void

    var body: some View {
        HStack(spacing: 12) {
            Button(action: {
                showingAttachmentSheet = true
            }) {
                Image(systemName: "plus.circle")
                    .font(.title)
                    .foregroundColor(.gray)
            }

            TextField("Message", text: $text, axis: .vertical)
                .padding(.horizontal, 12)
                .padding(.vertical, 7)
                .overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(
                            Color(UIColor.separator).opacity(0.5),
                            lineWidth: 1
                        )
                )
                .lineLimit(1...4)

            Button(action: onSend) {
                Image(systemName: "arrow.up.circle.fill")
                    .font(.title)
                    .foregroundColor(.blue)
            }
            .disabled(text.isEmpty)
        }
        .padding(.horizontal)
        .padding(.vertical, 8)
        .background(Color(.systemBackground))
        .overlay(
            Rectangle()
                .frame(height: 0.5)
                .foregroundColor(Color(.separator)),
            alignment: .top
        )
        .confirmationDialog(
            "Choose an attachment",
            isPresented: $showingAttachmentSheet,
            actions: {
                Button("Photo") {
                    // Handle photo selection
                }

                Button("Camera") {
                    // Handle camera
                }

                Button("Document") {
                    // Handle document selection
                }

                Button("Cancel", role: .cancel) {
                    // Cancel
                }
            }
        )
    }
}

Handling Keyboard Appearance

To make the toolbar respond to keyboard appearance, you can combine it with keyboard observation:

struct KeyboardAwareChatView: View {
    @State private var messageText = ""
    @State private var keyboardHeight: CGFloat = 0
    
    var body: some View {
        VStack {
            // Your content here
            Text("Chat content")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .keyboardToolbar(height: 60 + keyboardHeight) {
            ChatInputBar(text: $messageText) {
                // Send message
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
            if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
                keyboardHeight = keyboardFrame.height
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
            keyboardHeight = 0
        }
    }
}