Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ios/MobNode.h
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,12 @@ NS_ASSUME_NONNULL_BEGIN
@property(nonatomic, strong, nullable)
NSDictionary *nativeViewProps; // full props dict forwarded to the factory

// Accessibility — set from the tap tag atom name; read by XCTest / ui_describe_all
// Accessibility — identifiers support test addressing; labels and disabled
// state describe composite controls such as tappable boxes.
@property(nonatomic, copy, nullable) NSString *accessibilityId;
@property(nonatomic, copy, nullable) NSString *accessibilityLabel;
@property(nonatomic, copy, nullable) NSString *accessibilityRole;
@property(nonatomic) BOOL disabled;

// Icon — logical name resolved to an SF Symbol on iOS / Material Symbol
// on Android. textSize and textColor control glyph sizing + tint.
Expand Down Expand Up @@ -271,15 +275,15 @@ NS_ASSUME_NONNULL_BEGIN
// system-default box in practice), but a sheet's corners are visibly
// square-vs-rounded, so `sheetCornerRadius` gets its own -1 sentinel
// (unset — use the system default) instead. `sheetDetents` is the raw
// "medium"/"large" string list from Mob.Renderer — mapped to
// built-in strings or content-detent dictionaries from Mob.Renderer — mapped to
// PresentationDetent by MobSheetView (see MobRootView.swift), not here, so
// this header stays framework-agnostic. Indicator geometry defaults to -1
// (unset — use the system default indicator); Mob.UI.sheet's validation
// guarantees all four arrive together or not at all, so checking any one
// for >= 0 is enough to know whether a complete custom indicator was
// supplied.
@property(nonatomic) CGFloat sheetCornerRadius;
@property(nonatomic, strong, nullable) NSArray<NSString *> *sheetDetents;
@property(nonatomic, strong, nullable) NSArray *sheetDetents;
@property(nonatomic, strong, nullable) UIColor *dragIndicatorColor;
@property(nonatomic) CGFloat dragIndicatorWidth;
@property(nonatomic) CGFloat dragIndicatorHeight;
Expand Down
224 changes: 213 additions & 11 deletions ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,62 @@
// The fixed-width path is what makes circular ring cells possible without
// a dedicated primitive — set width: N, height: N, corner_radius: N/2,
// border_color + border_width and the box renders as a ring.
// Tap wiring and accessibility semantics for a composite Box, kept out of
// MobBox's main modifier chain so the Swift type checker can cope.
private struct MobBoxSemantics: ViewModifier {
let node: MobNode

/// True when the box stands in for a control rather than plain layout —
/// it carries a label, or the caller asked for a button role. Only then
/// is it right to collapse the subtree into one accessibility element; a
/// passive box with labelled children must keep them visible to VoiceOver.
private var isAccessibilityControl: Bool {
node.accessibilityLabel != nil || node.accessibilityRole == "button"
}

// Only .isButton is set explicitly. SwiftUI's AccessibilityTraits has no
// .isNotEnabled member — the disabled trait is not something you add, it
// is what `.disabled(true)` below already publishes to VoiceOver. An
// explicit `.accessibilityAddTraits(.isNotEnabled)` does not compile.
private var traits: AccessibilityTraits {
node.accessibilityRole == "button" ? .isButton : []
}

func body(content: Content) -> some View {
content
// Branch on `onTap` presence only, never on `disabled`. `ifLet` is
// @ViewBuilder if/else, i.e. _ConditionalContent — flipping the
// branch gives SwiftUI a structurally different view and tears the
// subtree down. `disabled` is routinely toggled, so branching on it
// would drop a wrapped TextField's in-flight text and focus, and
// re-present a wrapped Sheet. The check moves inside the closure,
// where it costs nothing structurally.
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture {
if !node.disabled { tap() }
}
}
// Collapse to a single accessibility element whenever this box is
// acting as a control. Traits added without collapsing land on
// every descendant instead, so a role-only box would announce each
// nested Text as its own button.
.ifLet(isAccessibilityControl ? () : nil) { view, _ in
view.accessibilityElement(children: .ignore)
}
.ifLet(node.accessibilityLabel) { view, label in
view.accessibilityLabel(label)
}
// One OptionSet, so no _ConditionalContent branch on `disabled`.
.accessibilityAddTraits(traits)
// .disabled, not .allowsHitTesting: allowsHitTesting(false) makes
// the view transparent to touches, so a disabled box used as a
// blocking overlay or dimmed backdrop would pass taps through to
// whatever sits behind it. .disabled blocks interaction in the
// subtree while still consuming the touch.
.disabled(node.disabled)
}
}

private struct MobBox: View {
let node: MobNode

Expand Down Expand Up @@ -683,10 +739,13 @@
lineWidth: node.borderWidth)
.allowsHitTesting(false)
)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.mobGestures(node)
// Interaction + accessibility live in their own ViewModifier: folding
// them into this chain inline pushed it past SwiftUI's type-inference
// budget and the Swift build failed outright ("unable to type-check
// this expression in reasonable time"). Same reason MobBox itself was
// extracted from MobNodeView.
.modifier(MobBoxSemantics(node: node))
// (offset is applied uniformly by MobNodeView's body; not here)
}
}
Expand Down Expand Up @@ -1090,7 +1149,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 1152 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand Down Expand Up @@ -1416,10 +1475,52 @@
// both fall out for free: a rerender with the sheet still present reuses
// this state; a rerender without it tears the view (and its presentation)
// down entirely.
// Measured intrinsic content height plus the bottom safe-area inset that
// applies *inside* the sheet. `.presentationDetents(.height(x))` sets the
// sheet's TOTAL height, but the content region is inset by the home indicator,
// so a detent of exactly the content height leaves the last rows under it and
// makes a sheet that should fit exactly scroll instead.
private struct MobSheetContentMetrics: Equatable {
var height: CGFloat = 0
var bottomInset: CGFloat = 0
}

private struct MobSheetContentHeightKey: PreferenceKey {
static var defaultValue = MobSheetContentMetrics()

static func reduce(value: inout MobSheetContentMetrics, nextValue: () -> MobSheetContentMetrics) {
let next = nextValue()
value = MobSheetContentMetrics(
height: max(value.height, next.height),
bottomInset: max(value.bottomInset, next.bottomInset)
)
}
}

// 0 means "root geometry not read yet". Distinguishing unknown from a real
// measurement matters: defaulting to a small number collapsed the first
// presentation of every content sheet to a hairline.
private struct MobAvailableSheetHeightKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}

private extension EnvironmentValues {
var mobAvailableSheetHeight: CGFloat {
get { self[MobAvailableSheetHeightKey.self] }
set { self[MobAvailableSheetHeightKey.self] = newValue }
}
}

private struct MobSheetView: View {
let node: MobNode
@Environment(\.mobAvailableSheetHeight) private var availableHeight
@State private var isPresented = true
@State private var dismissSent = false
// nil until the content has actually been measured. A numeric sentinel
// here is what produced a 1pt sheet on first presentation: content can
// only be measured after the sheet is up, so the first detent was
// computed from the sentinel.
@State private var contentMetrics: MobSheetContentMetrics?

var body: some View {
Color.clear
Expand All @@ -1435,15 +1536,80 @@
node.onDismiss?()
}

@ViewBuilder
private var sheetContent: some View {
private var contentDetent: [String: Any]? {
node.sheetDetents?.compactMap { $0 as? [String: Any] }
.first { $0["type"] as? String == "content" }
}

/// Ceiling for the sheet. `availableHeight` is 0 until the root has
/// reported geometry; treat that as "no ceiling known yet" rather than
/// clamping to it, so an unmeasured root can never collapse the sheet.
private var maximumHeight: CGFloat {
let configured = (contentDetent?["max_height"] as? NSNumber).map { CGFloat(truncating: $0) }

switch (configured, availableHeight > 0) {
case let (.some(limit), true): return min(limit, availableHeight)
case let (.some(limit), false): return limit
case (.none, true): return availableHeight
case (.none, false): return .greatestFiniteMagnitude
}
}

/// Total sheet height for the detent: measured content plus the sheet's
/// own bottom safe-area inset, capped. nil while unmeasured.
private var limitedContentHeight: CGFloat? {
guard let metrics = contentMetrics, metrics.height > 0 else { return nil }
return max(1, min(metrics.height + metrics.bottomInset, maximumHeight))
}

private var sheetBody: some View {
VStack(spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in
MobNodeView(node: child)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(node.paddingEdgeInsets)
}

@ViewBuilder
private var sheetContent: some View {
Group {
if contentDetent != nil {
ScrollView(.vertical) {
sheetBody
.fixedSize(horizontal: false, vertical: true)
.background {
GeometryReader { geometry in
Color.clear.preference(
key: MobSheetContentHeightKey.self,
value: MobSheetContentMetrics(
height: geometry.size.height,
bottomInset: geometry.safeAreaInsets.bottom
)
)
}
}
}
.frame(maxHeight: maximumHeight)
.onPreferenceChange(MobSheetContentHeightKey.self) { measured in
// Ignore sub-point churn so a measurement that feeds the
// detent, which resizes the sheet, which re-measures,
// settles instead of oscillating.
let changed =
contentMetrics.map {
abs(measured.height - $0.height) > 0.5
|| abs(measured.bottomInset - $0.bottomInset) > 0.5
} ?? true

if changed, measured.height > 0 {
contentMetrics = measured
}
}
} else {
sheetBody
}
}
// Screen readers should treat the sheet as a self-contained modal —
// VoiceOver focus stays inside it until dismissed, matching
// .presentationDetents/.sheet's own system-modal behavior.
Expand All @@ -1470,13 +1636,25 @@
}

private var detentSet: Set<PresentationDetent> {
let requested = node.sheetDetents ?? ["medium", "large"]
if contentDetent != nil {
// Content can only be measured once the sheet is on screen, so the
// first evaluation has nothing to size against. Present at .medium
// for that one frame and switch to the exact height as soon as the
// measurement lands — the previous sentinel-based version resolved
// to .height(1) and flashed a hairline on every presentation.
guard let height = limitedContentHeight else { return [.medium] }
return [.height(height)]
}

let builtInDetents = node.sheetDetents?.compactMap { $0 as? String } ?? []
var resolved: Set<PresentationDetent> = []
if requested.contains("medium") { resolved.insert(.medium) }
if requested.contains("large") { resolved.insert(.large) }
// Mob.UI.sheet/2 already validates :detents is a nonempty subset of
// [:medium, :large] — this fallback only matters for a hand-built
// node map that skipped that validation (e.g. `~MOB` sigil literal).
if builtInDetents.contains("medium") { resolved.insert(.medium) }
if builtInDetents.contains("large") { resolved.insert(.large) }
// Mob.Renderer normalizes :detents through Mob.UI.normalize_sheet_detents!/1
// at the encode boundary, so an invalid list now raises before it ever
// reaches here rather than arriving as an unknown string. This fallback
// is therefore only reachable for a node whose detents key is absent
// entirely.
return resolved.isEmpty ? [.medium, .large] : resolved
}

Expand Down Expand Up @@ -1555,6 +1733,12 @@
// SwiftUI observation, which doesn't carry the animation context and
// produces a default crossfade instead of the .move transition).
@State private var currentNavVersion: Int = 0
@State private var availableSheetHeight: CGFloat = 1

/// Share of the root's height a content-detent sheet may occupy at most.
/// Keeps a tall sheet visibly a sheet — parent still showing behind it —
/// rather than an unrecognisable full-screen cover.
private static let sheetHeightCeilingFraction: CGFloat = 0.9

public init() {}

Expand Down Expand Up @@ -1601,6 +1785,24 @@
.transition(.opacity)
}
}
// Live root height, published so a content-detent sheet can cap itself
// against real geometry rather than a guess. Read in a `.background`
// so it costs no layout, and `initial: true` so the first value lands
// without waiting for a resize. Re-fires on rotation, split view and
// Stage Manager resizes, which is what re-clamps a presented sheet.
//
// The ceiling is a fraction of the root rather than the whole thing:
// a content sheet that measures taller than the screen should still
// leave the parent visible behind it, the way .large does, instead of
// becoming an indistinguishable full-screen cover.
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.size.height, initial: true) { _, height in
availableSheetHeight = max(1, height * Self.sheetHeightCeilingFraction)
}
}
}
.environment(\.mobAvailableSheetHeight, availableSheetHeight)
.ignoresSafeArea(.container, edges: [.bottom, .horizontal])
.onChange(of: model.rootVersion) {
let t = model.transition
Expand Down
15 changes: 15 additions & 0 deletions ios/mob_nif.m
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,21 @@ static void mob_send_change_float(int handle, double value) {
if ([accessibilityId isKindOfClass:[NSString class]]) {
node.accessibilityId = accessibilityId;
}

id accessibilityLabel = props[@"accessibility_label"];
if ([accessibilityLabel isKindOfClass:[NSString class]]) {
node.accessibilityLabel = accessibilityLabel;
}

id accessibilityRole = props[@"accessibility_role"];
if ([accessibilityRole isKindOfClass:[NSString class]]) {
node.accessibilityRole = accessibilityRole;
}

id disabled = props[@"disabled"];
if ([disabled isKindOfClass:[NSNumber class]]) {
node.disabled = [disabled boolValue];
}
}

NSArray *children = dict[@"children"];
Expand Down
11 changes: 10 additions & 1 deletion lib/mob/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ defmodule Mob.Renderer do

require Logger

alias Mob.{Style, Theme}
alias Mob.{Style, Theme, UI}

@default_nif :mob_nif

Expand Down Expand Up @@ -370,6 +370,12 @@ defmodule Mob.Renderer do
{:on_dismiss, {pid, tag}} when is_pid(pid) ->
[{"on_dismiss", nif.register_tap({pid, tag})}]

{:detents, detents} ->
encoded_detents =
detents |> UI.normalize_sheet_detents!() |> Enum.map(&encode_sheet_detent/1)

[{"detents", encoded_detents}]

# IME composition — fires for languages with multi-stage input (CJK,
# Korean, Vietnamese, accent input). Phase atom is :began | :updating
# | :committed | :cancelled. Apps that need commit-only behaviour
Expand Down Expand Up @@ -662,6 +668,9 @@ defmodule Mob.Renderer do

defp encode_native_value(v), do: v

defp encode_sheet_detent(%{} = detent), do: encode_native_config(detent)
defp encode_sheet_detent(detent) when is_atom(detent), do: Atom.to_string(detent)

# Encode one Mob.Canvas op map for the wire. The `:op` atom becomes a
# string ("line"/"circle"/...) so the native dispatch is a string switch.
# `:color` is resolved through the same two-step theme path as top-level
Expand Down
5 changes: 5 additions & 0 deletions lib/mob/screen_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ defmodule Mob.ScreenCase do
# PascalCase tag per line, converted to the snake_case `:type` atom the same
# way the sigil does). Plus `:native_view`, the runtime-only escape hatch that
# plugin / custom components serialize to and which has no template tag.
# Same compile-time-read caveat as Mob.Sigil's @known_tags: declare the
# manifests external resources or a tag addition won't recompile this module.
@external_resource Application.app_dir(:mob, "priv/tags/ios.txt")
@external_resource Application.app_dir(:mob, "priv/tags/android.txt")

@renderable_types (
read = fn name ->
path = Application.app_dir(:mob, "priv/tags/#{name}")
Expand Down
8 changes: 8 additions & 0 deletions lib/mob/sigil.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ defmodule Mob.Sigil do

# ── Whitelist ────────────────────────────────────────────────────────────────

# Read at compile time into a module attribute, so the manifests have to be
# declared external resources or editing them recompiles nothing. Without
# this, adding a tag leaves anyone with a warm _build — every path-dep
# consumer, which is the standard mob dev setup — with the sigil still
# rejecting the new tag until a manual `mix clean`.
@external_resource Application.app_dir(:mob, "priv/tags/ios.txt")
@external_resource Application.app_dir(:mob, "priv/tags/android.txt")

@known_tags (
ios_file = Application.app_dir(:mob, "priv/tags/ios.txt")
android_file = Application.app_dir(:mob, "priv/tags/android.txt")
Expand Down
Loading
Loading