tauri-plugin-system-components 0.1.2

Native system UI components for Tauri 2 — native iOS tab bar over the webview, native controls, and glass window backgrounds on macOS/iOS.
Documentation
//
//  SheetOverlay.swift
//  tauri-plugin-system-components
//
//  A native bottom sheet (UISheetPresentationController — real detents,
//  grabber, drag-to-dismiss) whose rows are rendered NATIVELY from data the
//  web app passes. On iOS 26 it sits on a Liquid Glass background; on older
//  systems a material blur. Beyond simple tappable rows, rows can be form
//  controls (text field, toggle, date picker, select, submit) so whole screens
//  — menus AND forms — render natively. Interaction reports back to JS:
//    • tappable row  → `sheetRow`     {sheetId, rowId}
//    • field change  → `sheetField`   {sheetId, rowId, value}
//    • submit button → `sheetSubmit`  {sheetId, rowId, values(JSON)}
//    • user dismiss  → `sheetDismissed`{sheetId}
//

import UIKit

enum SheetRowKind: String {
    case header  // non-tappable identity (avatar + name + email)
    case action  // tappable row → sheetRow
    case text  // multi-line paragraph, display only
    case textfield  // single-line text input
    case toggle  // UISwitch
    case datetime  // UIDatePicker (.dateAndTime)
    case select  // value chosen from `options` via a menu
    case submit  // prominent button → sheetSubmit(values)
}

struct SheetOption {
    let value: String
    let label: String
}

struct SheetRow {
    let id: String
    let kind: SheetRowKind
    let label: String
    let detail: String?
    let image: UIImage?
    let sfSymbol: String?
    let badge: String?
    let destructive: Bool
    let value: String?  // textfield text / datetime ISO-8601 / select value
    let on: Bool  // toggle
    let placeholder: String?
    let options: [SheetOption]  // select
}

final class SheetController: UIViewController, UITableViewDataSource, UITableViewDelegate,
    UIAdaptivePresentationControllerDelegate
{
    var rows: [SheetRow] = []
    var tint: UIColor?
    var onSelect: ((String) -> Void)?  // action row tapped
    var onField: ((String, String) -> Void)?  // (rowId, value) field changed
    var onSubmit: ((String, [String: String]) -> Void)?  // (rowId, all field values)
    var onDismissed: (() -> Void)?

    private let table = UITableView(frame: .zero, style: .insetGrouped)
    /// Live field values keyed by rowId, seeded from the rows' initial values.
    private var values: [String: String] = [:]
    private static let iso = ISO8601DateFormatter()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .clear

        let effect: UIVisualEffect = {
            // UIGlassEffect ships in the iOS 26 SDK (Xcode 26 / Swift 6.2);
            // compile it out on older SDKs that lack the symbol.
            #if compiler(>=6.2)
            if #available(iOS 26.0, *) { return UIGlassEffect() }
            #endif
            return UIBlurEffect(style: .systemThickMaterial)
        }()
        let glass = UIVisualEffectView(effect: effect)
        glass.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(glass)

        table.translatesAutoresizingMaskIntoConstraints = false
        table.backgroundColor = .clear
        table.dataSource = self
        table.delegate = self
        table.keyboardDismissMode = .interactive
        if let tint { table.tintColor = tint }
        view.addSubview(table)

        NSLayoutConstraint.activate([
            glass.topAnchor.constraint(equalTo: view.topAnchor),
            glass.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            glass.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            glass.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            table.topAnchor.constraint(equalTo: view.topAnchor),
            table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])

        seedValues(rows)
    }

    /// Seed field values from rows' initial state — only for keys not already
    /// held, so a live update never clobbers what the user has typed.
    private func seedValues(_ rows: [SheetRow]) {
        for r in rows where values[r.id] == nil {
            switch r.kind {
            case .toggle: values[r.id] = r.on ? "true" : "false"
            case .textfield, .datetime, .select: if let v = r.value { values[r.id] = v }
            default: break
            }
        }
    }

    /// Re-present in place: swap rows/tint and reload WITHOUT a dismiss→present
    /// cycle (which races and flickers). Used when the same sheet id is
    /// presented again with new content (e.g. a list row was removed).
    func update(rows newRows: [SheetRow], tint newTint: UIColor?) {
        rows = newRows
        tint = newTint
        seedValues(newRows)
        if isViewLoaded {
            if let newTint { table.tintColor = newTint }
            table.reloadData()
        }
    }

    // MARK: data source

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        rows.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let row = rows[indexPath.row]
        // Fresh cell each time (forms are short) so reused controls never leak.
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.4)
        cell.selectionStyle = .none

        var config = cell.defaultContentConfiguration()
        config.text = row.label
        config.secondaryText = row.detail
        if let image = row.image {
            config.image = image
        } else if let symbol = row.sfSymbol {
            config.image = UIImage(systemName: symbol)
        }
        config.imageProperties.maximumSize = CGSize(
            width: row.kind == .header ? 40 : 24, height: row.kind == .header ? 40 : 24)
        config.imageProperties.tintColor =
            row.destructive ? .systemRed : (row.kind == .header ? .label : .secondaryLabel)

        switch row.kind {
        case .header:
            config.textProperties.font = .preferredFont(forTextStyle: .headline)
            config.secondaryTextProperties.color = .secondaryLabel
            cell.contentConfiguration = config

        case .text:
            config.textProperties.numberOfLines = 0
            config.textProperties.color = .label
            config.secondaryTextProperties.numberOfLines = 0
            cell.contentConfiguration = config

        case .action:
            if row.destructive { config.textProperties.color = .systemRed }
            cell.contentConfiguration = config
            cell.selectionStyle = .default
            if let badge = row.badge {
                cell.accessoryView = makeBadge(badge)
            } else {
                cell.accessoryType = .disclosureIndicator
            }

        case .submit:
            config.text = nil
            cell.contentConfiguration = config
            let button = UIButton(type: .system)
            button.setTitle(row.label, for: .normal)
            button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
            button.translatesAutoresizingMaskIntoConstraints = false
            let id = row.id
            button.addAction(
                UIAction { [weak self] _ in
                    guard let self else { return }
                    self.view.endEditing(true)
                    self.onSubmit?(id, self.values)
                }, for: .touchUpInside)
            cell.contentView.addSubview(button)
            NSLayoutConstraint.activate([
                button.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 8),
                button.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -8),
                button.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor),
                button.trailingAnchor.constraint(equalTo: cell.contentView.trailingAnchor),
            ])

        case .toggle:
            cell.contentConfiguration = config
            let sw = UISwitch()
            sw.isOn = (values[row.id] ?? "false") == "true"
            if let tint { sw.onTintColor = tint }
            let id = row.id
            sw.addAction(
                UIAction { [weak self, weak sw] _ in
                    let v = (sw?.isOn ?? false) ? "true" : "false"
                    self?.values[id] = v
                    self?.onField?(id, v)
                }, for: .valueChanged)
            cell.accessoryView = sw

        case .textfield:
            cell.contentConfiguration = config
            let field = UITextField()
            field.text = values[row.id]
            field.placeholder = row.placeholder
            field.textAlignment = .right
            field.clearButtonMode = .whileEditing
            field.translatesAutoresizingMaskIntoConstraints = false
            field.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
            let id = row.id
            field.addAction(
                UIAction { [weak self, weak field] _ in
                    let v = field?.text ?? ""
                    self?.values[id] = v
                    self?.onField?(id, v)
                }, for: .editingChanged)
            cell.accessoryView = field

        case .datetime:
            cell.contentConfiguration = config
            let picker = UIDatePicker()
            picker.datePickerMode = .dateAndTime
            if #available(iOS 14.0, *) { picker.preferredDatePickerStyle = .compact }
            if let v = values[row.id], let d = Self.iso.date(from: v) {
                picker.date = d
            } else {
                values[row.id] = Self.iso.string(from: picker.date)
            }
            let id = row.id
            picker.addAction(
                UIAction { [weak self, weak picker] _ in
                    let v = Self.iso.string(from: picker?.date ?? Date())
                    self?.values[id] = v
                    self?.onField?(id, v)
                }, for: .valueChanged)
            cell.accessoryView = picker

        case .select:
            cell.contentConfiguration = config
            let button = UIButton(type: .system)
            let current = values[row.id]
            button.setTitle(
                row.options.first { $0.value == current }?.label ?? (row.placeholder ?? "Select"),
                for: .normal)
            let id = row.id
            button.menu = UIMenu(
                children: row.options.map { opt in
                    UIAction(title: opt.label, state: opt.value == current ? .on : .off) {
                        [weak self, weak button] _ in
                        self?.values[id] = opt.value
                        self?.onField?(id, opt.value)
                        button?.setTitle(opt.label, for: .normal)
                    }
                })
            button.showsMenuAsPrimaryAction = true
            cell.accessoryView = button
        }
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let row = rows[indexPath.row]
        if row.kind == .action { onSelect?(row.id) }
    }

    private func makeBadge(_ text: String) -> UIView {
        let label = PaddedLabel()
        label.text = text
        label.font = .systemFont(ofSize: 12, weight: .semibold)
        label.textColor = .white
        label.backgroundColor = tint ?? .systemBlue
        label.layer.cornerRadius = 10
        label.clipsToBounds = true
        label.sizeToFit()
        return label
    }

    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
        onDismissed?()
    }
}

/// A small pill label for row badges (intrinsic size padded horizontally).
private final class PaddedLabel: UILabel {
    override var intrinsicContentSize: CGSize {
        let s = super.intrinsicContentSize
        return CGSize(width: s.width + 14, height: 20)
    }
    override func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize }
}