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
//
//  TabBarOverlay.swift
//  tauri-plugin-system-components
//
//  A UITabBar floated over the Tauri WKWebView as a child view controller of
//  the host VC. The bar is the official Apple component: when the app is
//  built with Xcode 26 against the iOS 26 SDK it adopts Liquid Glass
//  automatically and refracts the web content rendered behind it. On older
//  SDKs/devices the same bar renders the classic translucent look.
//

import UIKit

/// Root view of the overlay controller: covers the whole host view so the
/// tab bar can pin to the real screen bottom, but only claims touches that
/// land on the bar itself — everything else falls through to the webview.
final class PassthroughView: UIView {
    weak var interactiveView: UIView?
    /// The standalone account button beside the bar, if any — also interactive.
    weak var accessory: UIView?

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        guard let hit = super.hitTest(point, with: event) else { return nil }
        if let bar = interactiveView, !bar.isHidden, hit === bar || hit.isDescendant(of: bar) {
            return hit
        }
        if let acc = accessory, !acc.isHidden, hit === acc || hit.isDescendant(of: acc) {
            return hit
        }
        return nil
    }
}

final class TabBarOverlayController: UIViewController, UITabBarDelegate {
    let tabBar = UITabBar()
    private(set) var ids: [String] = []
    var onSelect: ((String) -> Void)?

    /// The bar's trailing edge — pinned to the screen edge normally, or to the
    /// account button's leading edge when an accessory is mounted (so the bar
    /// shrinks to leave room beside it, à la Apple Music's search button).
    private var trailingFull: NSLayoutConstraint!
    private var trailingToAccessory: NSLayoutConstraint?
    private var accessoryButton: UIButton?
    private(set) var accessoryId: String?

    /// The accessory's size and vertical placement are derived from the bar's
    /// live geometry (see `updateAccessoryGeometry`), so they are held here and
    /// refreshed on every layout pass rather than frozen at mount time.
    private var accessoryWidth: NSLayoutConstraint?
    private var accessoryHeight: NSLayoutConstraint?
    private var accessoryCenterY: NSLayoutConstraint?

    /// Avatar diameter, in points.
    private static let accessorySide: CGFloat = 50
    /// Gap between the avatar's trailing edge and the safe-area edge.
    private static let accessoryEdgeMargin: CGFloat = 16
    /// Gap between the bar's trailing edge and the avatar's leading edge.
    private static let accessoryGap: CGFloat = 14

    /// The bar's *visible* content-row height — its frame height minus the
    /// home-indicator inset it self-extends under. This is the height of the
    /// glass pill the user sees, so the accessory matches it to read as a
    /// sibling, and the icon row is centered within it.
    private var barContentHeight: CGFloat {
        let h = tabBar.bounds.height - tabBar.safeAreaInsets.bottom
        return h > 1 ? h : Self.accessorySide
    }

    override func loadView() {
        let passthrough = PassthroughView()
        passthrough.interactiveView = tabBar
        passthrough.backgroundColor = .clear
        view = passthrough
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        tabBar.delegate = self
        tabBar.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tabBar)
        trailingFull = tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        NSLayoutConstraint.activate([
            tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            trailingFull,
            // UITabBar self-extends its background under the home indicator
            // when pinned to the physical bottom edge.
            tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }

    /// Tints the selected item via the bar's appearance (preserving the iOS 26
    /// glass background) — plain `tintColor` is ignored by the glass selection
    /// pill, so the accent has to ride on the appearance's selected colors.
    func applyTint(_ color: UIColor?) {
        tabBar.tintColor = color
        guard let color else { return }
        let appearance = tabBar.standardAppearance
        for items in [
            appearance.stackedLayoutAppearance,
            appearance.inlineLayoutAppearance,
            appearance.compactInlineLayoutAppearance,
        ] {
            items.selected.iconColor = color
            items.selected.titleTextAttributes = [.foregroundColor: color]
        }
        tabBar.standardAppearance = appearance
        if #available(iOS 15.0, *) {
            tabBar.scrollEdgeAppearance = appearance
        }
    }

    /// Mounts (or, with `id == nil`, removes) the circular account button beside
    /// the bar. The avatar fills the circle; with no image, an SF Symbol over a
    /// glass disc. Taps report through `onSelect(id)` — the same channel as
    /// tabs — so the web app opens its account menu.
    func setAccessory(id: String?, image: UIImage?, sfSymbol: String?) {
        trailingToAccessory?.isActive = false
        trailingToAccessory = nil
        accessoryButton?.removeFromSuperview()
        accessoryButton = nil
        accessoryWidth = nil
        accessoryHeight = nil
        accessoryCenterY = nil
        (view as? PassthroughView)?.accessory = nil
        accessoryId = id
        trailingFull.isActive = true
        guard let id else { return }

        let side = Self.accessorySide
        let button = UIButton(type: .custom)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.clipsToBounds = true
        button.layer.cornerRadius = side / 2
        if let image {
            button.setImage(image, for: .normal)
            button.imageView?.contentMode = .scaleAspectFill
            button.contentHorizontalAlignment = .fill
            button.contentVerticalAlignment = .fill
        } else {
            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: .systemMaterial)
            }()
            let disc = UIVisualEffectView(effect: effect)
            disc.isUserInteractionEnabled = false
            disc.translatesAutoresizingMaskIntoConstraints = false
            button.insertSubview(disc, at: 0)
            NSLayoutConstraint.activate([
                disc.leadingAnchor.constraint(equalTo: button.leadingAnchor),
                disc.trailingAnchor.constraint(equalTo: button.trailingAnchor),
                disc.topAnchor.constraint(equalTo: button.topAnchor),
                disc.bottomAnchor.constraint(equalTo: button.bottomAnchor),
            ])
            let conf = UIImage.SymbolConfiguration(pointSize: side * 0.46, weight: .regular)
            button.setImage(
                UIImage(systemName: sfSymbol ?? "person.crop.circle.fill", withConfiguration: conf),
                for: .normal)
            button.tintColor = .label
        }
        button.addAction(UIAction { [weak self] _ in self?.onSelect?(id) }, for: .touchUpInside)
        view.addSubview(button)
        let guide = view.safeAreaLayoutGuide
        // Align the avatar to the bar's *content row* via the bar's own safe-area
        // layout guide: the guide excludes the home-indicator strip the bar
        // self-extends under, so its center is the glass-pill center. Centre the
        // avatar on that. The width/height constants are seeded from `side` and
        // refined in `updateAccessoryGeometry` once the bar reports a real frame,
        // so the avatar tracks the live content-row height across devices,
        // orientations, and SDK metric changes rather than freezing at 50pt.
        let barGuide = tabBar.safeAreaLayoutGuide
        let heightC = button.heightAnchor.constraint(equalToConstant: Self.accessorySide)
        let widthC = button.widthAnchor.constraint(equalToConstant: Self.accessorySide)
        let centerYC = button.centerYAnchor.constraint(equalTo: barGuide.centerYAnchor)
        NSLayoutConstraint.activate([
            widthC,
            heightC,
            button.trailingAnchor.constraint(
                equalTo: guide.trailingAnchor, constant: -Self.accessoryEdgeMargin),
            centerYC,
        ])
        accessoryWidth = widthC
        accessoryHeight = heightC
        accessoryCenterY = centerYC
        // Shrink the bar so the button sits beside it, with a gap between them.
        trailingFull.isActive = false
        let shrink = tabBar.trailingAnchor.constraint(
            equalTo: button.leadingAnchor, constant: -Self.accessoryGap)
        shrink.isActive = true
        trailingToAccessory = shrink
        accessoryButton = button
        (view as? PassthroughView)?.accessory = button
        view.setNeedsLayout()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        updateAccessoryGeometry()
    }

    /// Match the accessory button to the bar's live content-row geometry: a
    /// circle the same height as the visible glass pill, vertically centered on
    /// the bar's icon row. Anchoring to `tabBar.topAnchor` plus half the content
    /// height lands on that row's center regardless of the home-indicator inset
    /// the bar extends under — which is why this replaces the old hardcoded
    /// `-24.5` offset that only held for one device metric.
    private func updateAccessoryGeometry() {
        guard let button = accessoryButton, button.bounds.height > 1 else { return }
        // Drive the avatar's size from the bar's live content-row height (the
        // visible glass-pill height) so it reads as an equal-height sibling on
        // every device metric, instead of staying frozen at the seed `side`.
        let side = barContentHeight
        if let widthC = accessoryWidth, widthC.constant != side { widthC.constant = side }
        if let heightC = accessoryHeight, heightC.constant != side { heightC.constant = side }
        // Keep the avatar perfectly circular as that height resolves.
        button.layer.cornerRadius = button.bounds.height / 2
        NSLog(
            "[pendi-nav][native] TabBarOverlay geometry barFrame=%@ barSafeBottom=%.1f avatar=%@",
            NSCoder.string(for: tabBar.frame), tabBar.safeAreaInsets.bottom,
            NSCoder.string(for: button.frame))
    }

    /// Side of bitmap tab icons (avatars), in points.
    private static let imageSide: CGFloat = 26

    func apply(items: [TabItemArgs], selectedId: String?) {
        ids = items.map { $0.id }
        let barItems = items.enumerated().map { index, item -> UITabBarItem in
            var image: UIImage?
            if let b64 = item.image, let decoded = ImageUtil.decode(b64) {
                image = ImageUtil.icon(
                    decoded,
                    side: Self.imageSide,
                    circular: item.circular ?? false
                )
            } else if let symbol = item.sfSymbol {
                image = UIImage(systemName: symbol)
            }
            let barItem = UITabBarItem(title: item.title, image: image, tag: index)
            barItem.badgeValue = item.badge
            return barItem
        }
        let animated = !(tabBar.items?.isEmpty ?? true)
        tabBar.setItems(barItems, animated: animated)
        select(id: selectedId ?? ids.first)
        NSLog(
            "[pendi-nav][native] TabBarOverlay(separate) apply tabs=%d accessory=%@",
            ids.count, accessoryId ?? "(none)")
    }

    @discardableResult
    func select(id: String?) -> Bool {
        guard let id, let index = ids.firstIndex(of: id),
            let items = tabBar.items, index < items.count
        else { return false }
        tabBar.selectedItem = items[index]
        return true
    }

    @discardableResult
    func setBadge(id: String, value: String?) -> Bool {
        guard let index = ids.firstIndex(of: id),
            let items = tabBar.items, index < items.count
        else { return false }
        items[index].badgeValue = value
        return true
    }

    /// Height of the region the bar occludes at the bottom of the screen,
    /// in CSS points — what the web content should pad itself by.
    var bottomInset: CGFloat {
        view.layoutIfNeeded()
        guard !(tabBar.items?.isEmpty ?? true), !tabBar.isHidden else { return 0 }
        return max(0, view.bounds.height - tabBar.frame.minY)
    }

    // MARK: UITabBarDelegate

    func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        guard item.tag >= 0, item.tag < ids.count else { return }
        onSelect?(ids[item.tag])
    }
}