SwiftUI Chat Composer Component

A growing input bar whose trailing button changes job rather than multiplying: dictate when empty, send when there is a draft, stop while the model is answering. The field grows to a line limit then scrolls internally, so the send button can never be pushed off screen.

Message

Install the free SwiftUI chat composer

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKChatComposer.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKChatComposer(text: $draft, isStreaming: generating,
               onSend: send, onStop: cancel)

Full source · 267 lines · MIT

SKChatComposer.swift
//
//  SKChatComposer.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  The input bar for an assistant. Grows with the message, and its trailing
//  button changes job rather than multiplying.
//
//  One button occupies that corner and means three different things depending
//  on state — dictate when there is nothing to send, send when there is, stop
//  while the model is answering. Shipping three buttons instead is the usual
//  shortcut and it costs you the corner: on a phone, that is the only spot the
//  thumb reaches without moving, so it has to hold whatever action is live.
//
//  The field grows to `lineLimit` and then scrolls internally, which is the
//  behaviour every messaging app has and almost no reimplementation does —
//  an unbounded field eventually pushes the send button off the screen.
//
//  Usage:
//      SKChatComposer(text: $draft, isStreaming: generating,
//                     onSend: send, onStop: cancel)
//

import SwiftUI

public struct SKChatComposer: View {
    @Binding private var text: String
    private let isStreaming: Bool
    private let placeholder: String
    private let lineLimit: Int
    private let onSend: () -> Void
    private let onStop: (() -> Void)?
    private let onAttach: (() -> Void)?
    private let onVoice: (() -> Void)?

    @FocusState private var focused: Bool

    public init(
        text: Binding<String>,
        isStreaming: Bool = false,
        placeholder: String = "Message",
        lineLimit: Int = 6,
        onSend: @escaping () -> Void,
        onStop: (() -> Void)? = nil,
        onAttach: (() -> Void)? = nil,
        onVoice: (() -> Void)? = nil
    ) {
        self._text = text
        self.isStreaming = isStreaming
        self.placeholder = placeholder
        self.lineLimit = lineLimit
        self.onSend = onSend
        self.onStop = onStop
        self.onAttach = onAttach
        self.onVoice = onVoice
    }

    /// What the trailing button currently does.
    private enum Action { case send, stop, dictate }

    private var hasDraft: Bool {
        !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    private var action: Action {
        if isStreaming { return .stop }
        if hasDraft || onVoice == nil { return .send }
        return .dictate
    }

    public var body: some View {
        HStack(alignment: .bottom, spacing: SK.Space.sm) {
            if let onAttach {
                circleButton(
                    symbol: "plus",
                    label: "Add attachment",
                    foreground: SK.Palette.labelSecondary,
                    background: SK.Palette.fill
                ) {
                    onAttach()
                    SK.Haptic.play(.light)
                }
            }

            field
            trailingButton
        }
        .padding(.horizontal, SK.Space.md)
        .padding(.vertical, SK.Space.sm)
        .skSurface(.bar)
        .skAnimation(.snap, value: action)
    }

    private func circleButton(
        symbol: String,
        label: String,
        foreground: Color,
        background: Color,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            ZStack {
                Circle().fill(background).frame(width: 38, height: 38)
                Image(systemName: symbol)
                    .font(SK.Icon.symbol(SK.Icon.md, weight: .bold))
                    .foregroundStyle(foreground)
            }
        }
        .buttonStyle(.skPress(scale: 0.9, haptic: nil))
        .accessibilityLabel(label)
    }

    // MARK: Field

    private var field: some View {
        ZStack(alignment: .leading) {
            // A manual placeholder, because `TextField`'s own disappears the
            // instant the field is focused — before anything has been typed —
            // which leaves an empty bar with no hint of what it wants.
            if text.isEmpty {
                Text(placeholder)
                    .font(SK.Type_.body)
                    .foregroundStyle(SK.Palette.labelTertiary)
                    .allowsHitTesting(false)
            }

            TextField("", text: $text, axis: .vertical)
                .font(SK.Type_.body)
                .foregroundStyle(SK.Palette.label)
                .lineLimit(1...lineLimit)
                .focused($focused)
                .skCapitalization(.sentences)
                .tint(SK.Palette.accent)
                .onSubmit(submit)
        }
        .padding(.horizontal, SK.Space.md)
        .padding(.vertical, SK.Space.sm + 2)
        .background(SK.Palette.fill, in: RoundedRectangle(cornerRadius: SK.Radius.xl, style: .continuous))
        .overlay {
            RoundedRectangle(cornerRadius: SK.Radius.xl, style: .continuous)
                .strokeBorder(
                    focused ? SK.Palette.accent : SK.Palette.separator,
                    lineWidth: focused ? SK.Stroke.thick : SK.Stroke.thin
                )
        }
        .skAnimation(.snap, value: focused)
        .contentShape(Rectangle())
        .onTapGesture { focused = true }
        .accessibilityLabel(placeholder)
    }

    // MARK: Trailing button

    private var trailingButton: some View {
        Button(action: trigger) {
            ZStack {
                Circle()
                    .fill(buttonFill)
                    .frame(width: 38, height: 38)

                // Cross-faded and scaled rather than swapped, so the corner
                // reads as one control changing job instead of three buttons
                // taking turns.
                symbol("arrow.up", visible: action == .send)
                symbol("stop.fill", visible: action == .stop)
                symbol("mic.fill", visible: action == .dictate)
            }
        }
        .buttonStyle(.skPress(scale: 0.9, haptic: nil))
        .disabled(action == .send && !hasDraft)
        .accessibilityLabel(accessibilityLabel)
    }

    private func symbol(_ name: String, visible: Bool) -> some View {
        Image(systemName: name)
            .font(SK.Icon.symbol(action == .stop ? SK.Icon.sm : SK.Icon.md, weight: .bold))
            .foregroundStyle(symbolColor)
            .scaleEffect(visible ? 1 : 0.4)
            .opacity(visible ? 1 : 0)
            .skAnimation(.snap, value: visible)
    }

    private var buttonFill: Color {
        switch action {
        case .stop: return SK.Palette.label
        case .send: return hasDraft ? SK.Palette.accent : SK.Palette.fill
        case .dictate: return SK.Palette.fill
        }
    }

    private var symbolColor: Color {
        switch action {
        case .stop: return SK.Palette.surface
        case .send: return hasDraft ? Color.white : SK.Palette.labelTertiary
        case .dictate: return SK.Palette.labelSecondary
        }
    }

    private var accessibilityLabel: String {
        switch action {
        case .send: return "Send message"
        case .stop: return "Stop generating"
        case .dictate: return "Dictate message"
        }
    }

    private func trigger() {
        switch action {
        case .send: submit()
        case .stop:
            onStop?()
            SK.Haptic.play(.rigid)
        case .dictate:
            onVoice?()
            SK.Haptic.play(.light)
        }
    }

    private func submit() {
        guard hasDraft else { return }
        onSend()
        SK.Haptic.play(.light)
    }
}

#Preview("SKChatComposer") {
    @Previewable @State var draft = ""
    @Previewable @State var streaming = false
    @Previewable @State var sent: [String] = []

    VStack(spacing: 0) {
        ScrollView {
            VStack(spacing: SK.Space.xs) {
                ForEach(Array(sent.enumerated()), id: \.offset) { _, message in
                    Text(message)
                        .font(SK.Type_.body)
                        .foregroundStyle(Color.white)
                        .padding(.horizontal, SK.Space.lg)
                        .padding(.vertical, SK.Space.md)
                        .background(SK.Palette.accent, in: Capsule())
                        .frame(maxWidth: .infinity, alignment: .trailing)
                }
            }
            .padding(SK.Space.lg)
        }

        SKChatComposer(
            text: $draft,
            isStreaming: streaming,
            onSend: {
                sent.append(draft)
                draft = ""
                streaming = true
                Task {
                    try? await Task.sleep(for: .seconds(2))
                    streaming = false
                }
            },
            onStop: { streaming = false },
            onAttach: {},
            onVoice: {}
        )
    }
    .background(SK.Palette.surface)
}

Questions

Is this SwiftUI chat composer free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKChatComposer.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.