tauri-plugin-system-components 0.1.3

Native system UI components for Tauri 2 — native iOS tab bar over the webview, native controls, and glass window backgrounds on macOS/iOS.
Documentation
//
//  ComponentsOverlay.swift
//  tauri-plugin-system-components
//
//  The controller hosting native overlay components over the webview. It only
//  orchestrates — lifecycle (create / update / remove), optional glass-wrapping,
//  and anchoring. Each component's own rendering lives in its file under
//  `Components/`; the kind→builder mapping is `ComponentRegistry`.
//

import UIKit
import WebKit

/// Covers the host view but only claims touches landing on a managed
/// component — everything else falls through to the webview.
final class ComponentsPassthroughView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let hit = super.hitTest(point, with: event)
        return hit === self ? nil : hit
    }
}

final class ComponentsOverlayController: UIViewController {
    private var components: [String: (container: UIView, control: UIView, kind: String)] = [:]
    private weak var webViewRef: WKWebView?
    /// (id, event, on, value, detail)
    var onEvent: ((String, String, Bool?, Double?, String?) -> Void)?

    override func loadView() {
        let v = ComponentsPassthroughView()
        v.backgroundColor = .clear
        view = v
    }

    var isEmpty: Bool { components.isEmpty }

    // MARK: creation

    func create(_ args: CreateComponentArgs, webView: WKWebView?) {
        webViewRef = webView
        guard let container = build(args) else { return }

        let props = args.props
        let anchor = args.anchor ?? "topTrailing"
        if args.below ?? false, let webView {
            // Below the webview: make the webview transparent so unpainted DOM
            // regions reveal the native view (barcode-scanner pattern).
            webView.isOpaque = false
            webView.backgroundColor = .clear
            webView.scrollView.backgroundColor = .clear
            if anchor == "absolute" {
                webView.scrollView.insertSubview(container, at: 0)
                placeByFrame(container, in: webView.scrollView, anchor: anchor, props: props)
            } else if let parent = webView.superview {
                parent.insertSubview(container, belowSubview: webView)
                placeByFrame(container, in: parent, anchor: anchor, props: props)
            }
        } else if anchor == "absolute" || anchor == "fill" {
            view.addSubview(container)
            placeByFrame(container, in: view, anchor: anchor, props: props)
        } else {
            container.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(container)
            activateAnchorConstraints(
                for: container, anchor: anchor,
                dx: CGFloat(args.dx ?? 0), dy: CGFloat(args.dy ?? 0),
                inset: props?.inset.map { CGFloat($0) })
            if let control = components[args.id]?.control {
                if let w = props?.width {
                    control.widthAnchor.constraint(equalToConstant: CGFloat(w)).isActive = true
                }
                if let h = props?.height {
                    control.heightAnchor.constraint(equalToConstant: CGFloat(h)).isActive = true
                }
            }
        }
    }

    /// Build a component's view (recursively for `container` children), glass-wrap
    /// it if asked, and register it by id so updates/removal can find it. Returns
    /// the (possibly wrapped) view to mount.
    @discardableResult
    private func build(_ args: CreateComponentArgs) -> UIView? {
        remove(id: args.id)
        let ctx = ComponentContext(
            emit: { [weak self] id, event, on, value, detail in
                self?.onEvent?(id, event, on, value, detail)
            },
            webView: webViewRef,
            makeChild: { [weak self] childArgs in self?.build(childArgs) }
        )
        guard let control = ComponentRegistry.make(args, ctx) else { return nil }
        var container = control
        if args.props?.glass ?? false {
            container = wrapInGlassCapsule(control)
        }
        components[args.id] = (container, control, args.kind)
        return container
    }

    /// Frame-based placement (UIKit coordinates match CSS: y from the top).
    private func placeByFrame(
        _ container: UIView, in parent: UIView, anchor: String, props: ComponentPropsArgs?
    ) {
        if anchor == "fill" {
            container.frame = parent.bounds
            container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        } else {
            container.frame = CGRect(
                x: props?.x ?? 0,
                y: props?.y ?? 0,
                width: props?.width ?? 200,
                height: props?.height ?? 120
            )
        }
    }

    // MARK: updates

    func update(id: String, props: ComponentPropsArgs) -> Bool {
        guard let entry = components[id] else { return false }
        // Geometry updates (DOM scroll/resize sync) act on the mounted view.
        if props.x != nil || props.y != nil || props.width != nil || props.height != nil {
            var frame = entry.container.frame
            if let w = props.width { frame.size.width = CGFloat(w) }
            if let h = props.height { frame.size.height = CGFloat(h) }
            if let x = props.x { frame.origin.x = CGFloat(x) }
            if let y = props.y { frame.origin.y = CGFloat(y) }
            entry.container.frame = frame
        }
        ComponentRegistry.update(entry.control, kind: entry.kind, props: props)
        return true
    }

    func remove(id: String) {
        if let entry = components.removeValue(forKey: id) {
            entry.container.removeFromSuperview()
        }
    }

    // MARK: layout helpers

    private func wrapInGlassCapsule(_ control: UIView) -> UIView {
        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, *) {
            effect = UIGlassEffect()
        } else {
            effect = UIBlurEffect(style: .systemMaterial)
        }
        #else
        effect = UIBlurEffect(style: .systemMaterial)
        #endif
        let capsule = UIVisualEffectView(effect: effect)
        capsule.clipsToBounds = true
        capsule.layer.cornerRadius = 24
        control.translatesAutoresizingMaskIntoConstraints = false
        capsule.contentView.addSubview(control)
        NSLayoutConstraint.activate([
            control.leadingAnchor.constraint(equalTo: capsule.contentView.leadingAnchor, constant: 14),
            control.trailingAnchor.constraint(equalTo: capsule.contentView.trailingAnchor, constant: -14),
            control.topAnchor.constraint(equalTo: capsule.contentView.topAnchor, constant: 10),
            control.bottomAnchor.constraint(equalTo: capsule.contentView.bottomAnchor, constant: -10),
        ])
        return capsule
    }

    private func activateAnchorConstraints(
        for container: UIView, anchor: String, dx: CGFloat, dy: CGFloat, inset: CGFloat?
    ) {
        let guide = view.safeAreaLayoutGuide
        let margin: CGFloat = inset ?? 16
        var constraints: [NSLayoutConstraint] = []
        switch anchor {
        case "topLeading":
            constraints = [
                container.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: margin + dx),
                container.topAnchor.constraint(equalTo: guide.topAnchor, constant: margin + dy),
            ]
        case "bottomLeading":
            constraints = [
                container.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: margin + dx),
                container.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -(margin + dy)),
            ]
        case "bottomTrailing":
            constraints = [
                container.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -(margin + dx)),
                container.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -(margin + dy)),
            ]
        case "center":
            constraints = [
                container.centerXAnchor.constraint(equalTo: guide.centerXAnchor, constant: dx),
                container.centerYAnchor.constraint(equalTo: guide.centerYAnchor, constant: dy),
            ]
        // Edge-docked: span the cross-axis so a bottom/top bar fills the width
        // (and a side rail fills the height), letting its children lay out
        // across the edge instead of floating a content-sized blob at the
        // center. `inset` (margin) is the gap from the docked edge and the
        // side insets along it. `dx` nudges the whole bar horizontally (same
        // shift on both edges, so the width is unchanged); `dy` adjusts the gap
        // from the docked edge.
        case "bottom":
            constraints = [
                container.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: margin + dx),
                container.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -margin + dx),
                container.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -(margin + dy)),
            ]
        case "top":
            constraints = [
                container.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: margin + dx),
                container.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -margin + dx),
                container.topAnchor.constraint(equalTo: guide.topAnchor, constant: margin + dy),
            ]
        case "leading":
            constraints = [
                container.topAnchor.constraint(equalTo: guide.topAnchor, constant: margin),
                container.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -margin),
                container.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: margin + dx),
            ]
        case "trailing":
            constraints = [
                container.topAnchor.constraint(equalTo: guide.topAnchor, constant: margin),
                container.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -margin),
                container.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -(margin + dx)),
            ]
        default:  // topTrailing
            constraints = [
                container.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -(margin + dx)),
                container.topAnchor.constraint(equalTo: guide.topAnchor, constant: margin + dy),
            ]
        }
        NSLayoutConstraint.activate(constraints)
    }
}