From aa112b3f943e4eca935dc6ba85254414f2a3d665 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 14:13:23 +0100 Subject: [PATCH 1/4] Draw custom: icons in mobile-ui elements Core can now build an app's resources/icons/.svg into native assets and resolve custom: to them. This uses core's NativeIcon helpers wherever mobile-ui draws an icon from a name, so , buttons, chips, list items, text inputs, tab rows, the top bar and the bottom nav all take custom icons. On iOS, call sites that size an SF Symbol with a font now go through NativeIconView, which frames a custom icon to match. System symbols get the same modifiers as before. Menu labels use the closure Label so the icon can be an asset. Swipe actions only take the new path for custom: names, since they never went through the name mapping. On Android, the top bar actions and bottom nav items drew their own font glyph, so custom: names there now go to core's MaterialIcon. Everything else already did. VoiceOver and TalkBack labels that are made from the icon name drop the custom: prefix. Needs the core release that adds NativeIcon. Co-Authored-By: Claude Opus 5.5 --- resources/android/ListItemRenderer.kt | 2 +- resources/android/NavRenderers.kt | 43 ++++-- resources/ios/NativeUIButtonRenderer.swift | 6 +- resources/ios/NativeUIChipRenderer.swift | 2 +- .../ios/NativeUIFilledTextInputRenderer.swift | 4 +- resources/ios/NativeUIIconRenderer.swift | 2 +- resources/ios/NativeUIListItemRenderer.swift | 16 +- resources/ios/NativeUIListRenderer.swift | 6 +- resources/ios/NativeUINavRenderers.swift | 6 +- .../NativeUIOutlinedTextInputRenderer.swift | 4 +- resources/ios/NativeUISimpleRenderers.swift | 2 +- resources/ios/NativeUITabRowRenderer.swift | 4 +- tests/CustomIconTest.php | 141 ++++++++++++++++++ 13 files changed, 198 insertions(+), 40 deletions(-) create mode 100644 tests/CustomIconTest.php diff --git a/resources/android/ListItemRenderer.kt b/resources/android/ListItemRenderer.kt index 0cf7841..1fa6b8c 100644 --- a/resources/android/ListItemRenderer.kt +++ b/resources/android/ListItemRenderer.kt @@ -400,7 +400,7 @@ object ListItemRenderer { // `trailing_a11y_label` wins, else humanize the machine icon name // ("more_vert" -> "more vert") so it's never unlabeled. val iconButtonDescription = a11yLabel.ifEmpty { - effectiveValue.replace('_', ' ').replace('-', ' ') + effectiveValue.removePrefix(com.nativephp.mobile.ui.CUSTOM_ICON_PREFIX).replace('_', ' ').replace('-', ' ') } return { diff --git a/resources/android/NavRenderers.kt b/resources/android/NavRenderers.kt index 41c880b..a5d1bbe 100644 --- a/resources/android/NavRenderers.kt +++ b/resources/android/NavRenderers.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nativephp.mobile.R +import com.nativephp.mobile.ui.CUSTOM_ICON_PREFIX +import com.nativephp.mobile.ui.MaterialIcon import com.nativephp.mobile.ui.getIconName import com.nativephp.mobile.ui.nativerender.LocalSafeAreaBottom import com.nativephp.mobile.ui.nativerender.LocalSafeAreaTop @@ -112,21 +114,28 @@ object TopBarRenderer { val icon = action.props.getString("icon", "more_vert") val url = action.props.getString("url") val actionLabel = action.props.getString("label") - Text( - text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = textColor, - modifier = Modifier - .minimumInteractiveComponentSize() - .clickable(role = Role.Button) { - if (url.isNotEmpty()) { - try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {} - } else if (action.onPress != 0) { - NativeElementBridge.sendPressEvent(action.onPress, action.id) - } - } - .let { m -> - if (actionLabel.isNotEmpty()) m.semantics { contentDescription = actionLabel } else m + val actionModifier = Modifier + .minimumInteractiveComponentSize() + .clickable(role = Role.Button) { + if (url.isNotEmpty()) { + try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {} + } else if (action.onPress != 0) { + NativeElementBridge.sendPressEvent(action.onPress, action.id) } - ) + } + .let { m -> + if (actionLabel.isNotEmpty()) m.semantics { contentDescription = actionLabel } else m + } + // Custom SVG icons are drawables, not font ligatures, so they + // go through core's MaterialIcon. Font glyphs stay as they were. + if (icon.startsWith(CUSTOM_ICON_PREFIX)) { + MaterialIcon(name = icon, contentDescription = null, modifier = actionModifier, size = 24.dp, tint = textColor) + } else { + Text( + text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = textColor, + modifier = actionModifier + ) + } } } } @@ -211,7 +220,11 @@ object BottomNavRenderer { // Box wraps the icon at center; badge / news dot anchored // to the icon's TopEnd corner via `.align(TopEnd)`. Box(contentAlignment = Alignment.Center) { - Text(text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = itemColor, textAlign = TextAlign.Center) + if (icon.startsWith(CUSTOM_ICON_PREFIX)) { + MaterialIcon(name = icon, contentDescription = null, size = 24.dp, tint = itemColor) + } else { + Text(text = getIconName(icon), fontFamily = iconFont, fontSize = 24.sp, color = itemColor, textAlign = TextAlign.Center) + } if (badge.isNotEmpty()) { Box( modifier = Modifier diff --git a/resources/ios/NativeUIButtonRenderer.swift b/resources/ios/NativeUIButtonRenderer.swift index 312cab5..20f071a 100644 --- a/resources/ios/NativeUIButtonRenderer.swift +++ b/resources/ios/NativeUIButtonRenderer.swift @@ -396,7 +396,7 @@ private struct ButtonContent: View { } } else { if !icon.isEmpty { - Image(systemName: getIconForName(icon)) + NativeIconView(icon, pointSize: iconSize, relativeTo: .body) .nuiScaledFont(size: iconSize) // Pin the layout height to iconSize. SF Symbols report // per-symbol intrinsic heights at the same point size, @@ -411,7 +411,7 @@ private struct ButtonContent: View { Text(label).nuiScaledFont(size: textSize, weight: .medium, fontName: fontName).lineSpacing(lineSpacing) } if !iconTrailing.isEmpty { - Image(systemName: getIconForName(iconTrailing)) + NativeIconView(iconTrailing, pointSize: iconSize, relativeTo: .body) .nuiScaledFont(size: iconSize) .frame(height: iconSize) } @@ -465,7 +465,7 @@ private func buttonMenuItem(_ item: NativeUINode) -> some View { } } label: { if !icon.isEmpty { - Label(label, systemImage: getIconForName(icon)) + Label { Text(label) } icon: { NativeIcon.image(icon) } } else { Text(label) } diff --git a/resources/ios/NativeUIChipRenderer.swift b/resources/ios/NativeUIChipRenderer.swift index c073b8a..fad99e5 100644 --- a/resources/ios/NativeUIChipRenderer.swift +++ b/resources/ios/NativeUIChipRenderer.swift @@ -66,7 +66,7 @@ struct NativeUIChipRenderer: View { }) { HStack(spacing: 6) { if !iconName.isEmpty { - Image(systemName: getIconForName(iconName)) + NativeIconView(iconName, pointSize: 14, relativeTo: .body) .nuiScaledFont(size: 14) } Text(label).nuiScaledFont(size: theme.fontSm, weight: .medium) diff --git a/resources/ios/NativeUIFilledTextInputRenderer.swift b/resources/ios/NativeUIFilledTextInputRenderer.swift index f8c6f51..385e920 100644 --- a/resources/ios/NativeUIFilledTextInputRenderer.swift +++ b/resources/ios/NativeUIFilledTextInputRenderer.swift @@ -68,7 +68,7 @@ struct NativeUIFilledTextInputRenderer: View { VStack(spacing: 0) { HStack(spacing: 8) { if !leadingIcon.isEmpty { - Image(systemName: getIconForName(leadingIcon)) + NativeIconView(leadingIcon, pointSize: metrics.iconSize, relativeTo: .body) .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } @@ -95,7 +95,7 @@ struct NativeUIFilledTextInputRenderer: View { if loading { ProgressView().controlSize(.small) } else if !trailingIcon.isEmpty { - Image(systemName: getIconForName(trailingIcon)) + NativeIconView(trailingIcon, pointSize: metrics.iconSize, relativeTo: .body) .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(theme.onSurfaceVariant) } diff --git a/resources/ios/NativeUIIconRenderer.swift b/resources/ios/NativeUIIconRenderer.swift index 5ed78a5..62dd317 100644 --- a/resources/ios/NativeUIIconRenderer.swift +++ b/resources/ios/NativeUIIconRenderer.swift @@ -27,7 +27,7 @@ struct NativeUIIconRenderer: View { ? darkArgb : lightArgb - Image(systemName: getIconForName(name)) + NativeIcon.image(name) .resizable() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) diff --git a/resources/ios/NativeUIListItemRenderer.swift b/resources/ios/NativeUIListItemRenderer.swift index 534cf7f..649a376 100644 --- a/resources/ios/NativeUIListItemRenderer.swift +++ b/resources/ios/NativeUIListItemRenderer.swift @@ -130,13 +130,13 @@ struct NativeUIListItemRenderer: View { if iconBgColor != 0 { ZStack { Circle().fill(Color(argb: iconBgColor)) - Image(systemName: getIconForName(value)) + NativeIconView(value, pointSize: 18, relativeTo: .body) .nuiScaledFont(size: 18, weight: .medium) .foregroundColor(.white) } .frame(width: 40, height: 40) } else { - Image(systemName: getIconForName(value)) + NativeIcon.image(value) .frame(width: 24, height: 24) .foregroundColor(.secondary) } @@ -225,7 +225,7 @@ struct NativeUIListItemRenderer: View { if !badges.isEmpty { HStack(spacing: 6) { ForEach(badges) { b in - Image(systemName: getIconForName(b.icon)) + NativeIconView(b.icon, pointSize: 15, relativeTo: .body) .nuiScaledFont(size: 15) .foregroundColor(parseListItemHex(b.color) ?? .secondary) } @@ -239,7 +239,7 @@ struct NativeUIListItemRenderer: View { private func buildTrailingContent(type: String, value: String, iconColor: Int, textColor: Int, checked: Bool = false, changeCb: Int = 0) -> some View { switch type { case "icon": - Image(systemName: getIconForName(value)) + NativeIcon.image(value) .frame(width: 24, height: 24) .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) case "text": @@ -252,7 +252,7 @@ struct NativeUIListItemRenderer: View { let trailingA11y = node.props.getString("trailing_a11y_label") let effectiveTrailingA11y = !trailingA11y.isEmpty ? trailingA11y - : value.replacingOccurrences(of: "_", with: " ") + : (NativeIcon.customName(value) ?? value).replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") // When the row has `:trailing-menu` attached, the trailing // icon button becomes a Menu trigger instead of a plain @@ -271,7 +271,7 @@ struct NativeUIListItemRenderer: View { // without it only the symbol's opaque pixels respond, // which makes the ellipsis glyph (mostly empty space) // almost un-tappable. - Image(systemName: getIconForName(value)) + NativeIcon.image(value) .frame(width: 24, height: 24) .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) .nuiMinTapTarget() @@ -289,7 +289,7 @@ struct NativeUIListItemRenderer: View { } } }) { - Image(systemName: getIconForName(value)) + NativeIcon.image(value) .frame(width: 24, height: 24) .foregroundColor(iconColor != 0 ? Color(argb: iconColor) : .secondary) .nuiMinTapTarget() @@ -331,7 +331,7 @@ private func listItemMenuItem(_ item: NativeUINode) -> some View { } } label: { if !icon.isEmpty { - Label(label, systemImage: getIconForName(icon)) + Label { Text(label) } icon: { NativeIcon.image(icon) } } else { Text(label) } diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 647f034..8a9e3b1 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -191,7 +191,11 @@ struct NativeUIListRenderer: View { let button = Button(role: role) { NativeElementBridge.sendPressEvent(spec.cb, nodeId: nodeId) } label: { - if !spec.icon.isEmpty { + if spec.icon.hasPrefix(NativeIcon.customPrefix) { + // Swipe icons are raw SF Symbol names (no getIconForName + // mapping), so only custom icons take the NativeIcon path. + Label { Text(spec.label.isEmpty ? " " : spec.label) } icon: { NativeIcon.image(spec.icon) } + } else if !spec.icon.isEmpty { Label(spec.label.isEmpty ? " " : spec.label, systemImage: spec.icon) } else { Text(spec.label.isEmpty ? " " : spec.label) diff --git a/resources/ios/NativeUINavRenderers.swift b/resources/ios/NativeUINavRenderers.swift index 092417a..7f012d7 100644 --- a/resources/ios/NativeUINavRenderers.swift +++ b/resources/ios/NativeUINavRenderers.swift @@ -62,14 +62,14 @@ struct NativeUITopBarRenderer: View { ? a11y : (!actionLabel.isEmpty ? actionLabel - : icon.replacingOccurrences(of: "_", with: " ") + : (NativeIcon.customName(icon) ?? icon).replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ")) Button { if action.onPress != 0 { NativeElementBridge.sendPressEvent(action.onPress, nodeId: action.id) } } label: { - Image(systemName: getIconForName(icon)) + NativeIconView(icon, pointSize: 17, relativeTo: .body) .nuiScaledFont(size: 17, weight: .semibold) .foregroundColor(textColor) .frame(width: 32, height: 32) @@ -166,7 +166,7 @@ struct NativeUIBottomNavRenderer: View { } label: { VStack(spacing: 4) { ZStack(alignment: .topTrailing) { - Image(systemName: getIconForName(icon)) + NativeIconView(icon, pointSize: 24, relativeTo: .body) .nuiScaledFont(size: 24) if !badge.isEmpty { Text(badge) diff --git a/resources/ios/NativeUIOutlinedTextInputRenderer.swift b/resources/ios/NativeUIOutlinedTextInputRenderer.swift index 67c69e6..bc1c1c2 100644 --- a/resources/ios/NativeUIOutlinedTextInputRenderer.swift +++ b/resources/ios/NativeUIOutlinedTextInputRenderer.swift @@ -97,7 +97,7 @@ struct NativeUIOutlinedTextInputRenderer: View { HStack(spacing: 8) { if !leadingIcon.isEmpty { - Image(systemName: getIconForName(leadingIcon)) + NativeIconView(leadingIcon, pointSize: metrics.iconSize, relativeTo: .body) .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(fieldDecorationColor) } @@ -128,7 +128,7 @@ struct NativeUIOutlinedTextInputRenderer: View { if loading { ProgressView().controlSize(.small) } else if !trailingIcon.isEmpty { - Image(systemName: getIconForName(trailingIcon)) + NativeIconView(trailingIcon, pointSize: metrics.iconSize, relativeTo: .body) .nuiScaledFont(size: metrics.iconSize) .foregroundStyle(fieldDecorationColor) } diff --git a/resources/ios/NativeUISimpleRenderers.swift b/resources/ios/NativeUISimpleRenderers.swift index 6ff762a..060e771 100644 --- a/resources/ios/NativeUISimpleRenderers.swift +++ b/resources/ios/NativeUISimpleRenderers.swift @@ -41,7 +41,7 @@ private func pressableMenuItem(_ item: NativeUINode) -> some View { } } label: { if !icon.isEmpty { - Label(label, systemImage: getIconForName(icon)) + Label { Text(label) } icon: { NativeIcon.image(icon) } } else { Text(label) } diff --git a/resources/ios/NativeUITabRowRenderer.swift b/resources/ios/NativeUITabRowRenderer.swift index 9a801bb..fd1574b 100644 --- a/resources/ios/NativeUITabRowRenderer.swift +++ b/resources/ios/NativeUITabRowRenderer.swift @@ -42,7 +42,7 @@ struct NativeUITabRowRenderer: View { let effectiveA11y = !tabA11y.isEmpty ? tabA11y : (label.isEmpty - ? icon.replacingOccurrences(of: "_", with: " ") + ? (NativeIcon.customName(icon) ?? icon).replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") : "") @@ -55,7 +55,7 @@ struct NativeUITabRowRenderer: View { }) { VStack(spacing: 4) { if !icon.isEmpty { - Image(systemName: getIconForName(icon)) + NativeIcon.image(icon) } if !label.isEmpty { Text(label).nuiScaledFont(size: theme.fontSm, weight: .medium) diff --git a/tests/CustomIconTest.php b/tests/CustomIconTest.php new file mode 100644 index 0000000..74cef01 --- /dev/null +++ b/tests/CustomIconTest.php @@ -0,0 +1,141 @@ +.svg` travel as the string + * `custom:` in every icon prop, and the renderers pick the asset + * from the prefix. These check that nothing on the PHP side lowercases, + * maps or drops that string on its way to the wire. + */ +enum CustomIconAndroid: string implements AndroidSymbol +{ + public function variant(): string + { + return 'outlined'; + } + + case Notifications = 'notifications'; +} + +beforeEach(function () { + NativeElementCollector::reset(); + ElementRegistry::reset(); + ElementRegistry::register('button', Button::class); + ElementRegistry::register('chip', Chip::class); + ElementRegistry::register('tab', Tab::class); + ElementRegistry::register('list_item', ListItem::class); + ElementRegistry::register('outlined_text_input', OutlinedTextInput::class); +}); + +afterEach(function () { + NativeElementCollector::reset(); + ElementRegistry::reset(); + Platform::set(null); +}); + +function compileNativeTag(string $blade): array +{ + $wasActive = NativeTagPrecompiler::setActive(true); + + try { + $php = (new NativeTagPrecompiler)($blade); + } finally { + NativeTagPrecompiler::setActive($wasActive); + } + + eval('?>'.$php); + + return NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']; +} + +it('keeps a custom name on a Blade ', function (string $element, ?string $platform) { + ElementRegistry::register('icon', $element); + Platform::set($platform); + + $props = compileNativeTag(''); + + expect($props['name'])->toBe('custom:git-commit-vertical') + ->and($props['size'])->toBe(20.0) + ->and($props)->not->toHaveKey('material_variant'); +})->with([ + 'core element' => CoreIcon::class, + 'plugin element' => Icon::class, +])->with(['ios', 'android', null]); + +it('keeps a custom name on the Icon builder', function (string $element) { + $props = $element::make('custom:git-commit-vertical')->toArray(new CallbackRegistry)['props']; + + expect($props['name'])->toBe('custom:git-commit-vertical'); +})->with([ + 'core element' => CoreIcon::class, + 'plugin element' => Icon::class, +]); + +it('keeps custom names in both button icon slots from Blade', function () { + Platform::set('ios'); + + $props = compileNativeTag( + '' + ); + + expect($props['leading_icon'])->toBe('custom:git-commit-vertical') + ->and($props['trailing_icon'])->toBe('custom:arrow-up') + ->and($props)->not->toHaveKey('leading_icon_variant'); +}); + +it('lets a custom button icon sit beside a Material override', function (string $platform, string $icon, ?string $variant) { + Platform::set($platform); + + $props = Button::make('Alerts') + ->icon('custom:bell', android: CustomIconAndroid::Notifications) + ->toArray(new CallbackRegistry)['props']; + + expect($props['leading_icon'])->toBe($icon) + ->and($props['leading_icon_variant'] ?? null)->toBe($variant); +})->with([ + 'ios draws the custom bell' => ['ios', 'custom:bell', null], + 'android draws the material glyph' => ['android', 'notifications', 'outlined'], +]); + +it('keeps custom names in every list item icon slot', function () { + Platform::set('android'); + + $props = ListItem::make('Commits') + ->leadingIcon('custom:git-commit-vertical') + ->trailingIconButton('custom:git-branch') + ->trailingActions([['method' => 'revert', 'label' => 'Revert', 'icon' => 'custom:undo']]) + ->trailingBadges([['icon' => 'custom:pin']]) + ->toArray(new CallbackRegistry)['props']; + + expect($props['leading_value'])->toBe('custom:git-commit-vertical') + ->and($props['leading_icon'])->toBe('custom:git-commit-vertical') + ->and($props['trailing_value'])->toBe('custom:git-branch') + ->and(json_decode($props['trailing_actions_json'], true)[0]['icon'])->toBe('custom:undo') + ->and(json_decode($props['trailing_badges_json'], true)[0]['icon'])->toBe('custom:pin'); +}); + +it('keeps custom names on chip, tab and text input icons', function (string $type, array $attrs, string $prop) { + Platform::set('ios'); + + NativeElementCollector::leaf($type, $attrs); + $props = NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']; + + expect($props[$prop])->toBe('custom:git-commit-vertical'); +})->with([ + 'chip' => ['chip', ['label' => 'Commits', 'icon' => 'custom:git-commit-vertical'], 'icon'], + 'tab' => ['tab', ['label' => 'Commits', 'icon' => 'custom:git-commit-vertical'], 'icon'], + 'text input' => ['outlined_text_input', ['leading-icon' => 'custom:git-commit-vertical'], 'leading_icon'], +]); From bc72ae45e5dc8604fd3d5c5db6c039081e7f3b95 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 16 Oct 2026 18:37:32 +0100 Subject: [PATCH 2/4] Add a Sign in with Apple button element draws Apple's own button on iOS, so apps don't have to fake it with a and an Apple logo. A tap only calls @press. The app starts the sign-in from there with whatever auth plugin it uses, so the element doesn't own any of it. Props: label (sign-in, continue, sign-up), button-style (auto, black, white, white-outline), corner-radius (or a rounded-* class), height (44 by default) and disabled. iOS wraps UIKit's ASAuthorizationAppleIDButton rather than SwiftUI's SignInWithAppleButton. The SwiftUI button's only initialiser runs the Apple ID request itself on every tap, so it can't be used as a plain tap target. The UIKit button only draws: the tap goes through a SwiftUI Button, because core's keyboard-dismiss tap gestures cancel touches in UIKit controls, so a target-action never fires. Android has no Apple button, so it draws nothing unless the tag has children, which render there as a fallback. Co-Authored-By: Claude Opus 5.5 --- nativephp.json | 8 + .../android/SignInWithAppleButtonRenderer.kt | 20 ++ resources/boost/guidelines/core.blade.php | 8 + ...ativeUISignInWithAppleButtonRenderer.swift | 209 ++++++++++++++++++ src/Components/SignInWithAppleButton.php | 17 ++ src/Elements/SignInWithAppleButton.php | 157 +++++++++++++ tests/SignInWithAppleButtonTest.php | 165 ++++++++++++++ 7 files changed, 584 insertions(+) create mode 100644 resources/android/SignInWithAppleButtonRenderer.kt create mode 100644 resources/ios/NativeUISignInWithAppleButtonRenderer.swift create mode 100644 src/Components/SignInWithAppleButton.php create mode 100644 src/Elements/SignInWithAppleButton.php create mode 100644 tests/SignInWithAppleButtonTest.php diff --git a/nativephp.json b/nativephp.json index 72ff62c..7530299 100644 --- a/nativephp.json +++ b/nativephp.json @@ -237,6 +237,14 @@ "ios_renderer": "NativeUIButtonRenderer", "self_closing": true }, + { + "type": "sign_in_with_apple_button", + "element": "Native\\Mobile\\UI\\Elements\\SignInWithAppleButton", + "blade": "Native\\Mobile\\UI\\Components\\SignInWithAppleButton", + "android_renderer": "com.nativephp.plugins.native_ui.ui.SignInWithAppleButtonRenderer", + "ios_renderer": "NativeUISignInWithAppleButtonRenderer", + "self_closing": false + }, { "type": "progress_bar", "element": "Native\\Mobile\\UI\\Elements\\ProgressBar", diff --git a/resources/android/SignInWithAppleButtonRenderer.kt b/resources/android/SignInWithAppleButtonRenderer.kt new file mode 100644 index 0000000..4efdc89 --- /dev/null +++ b/resources/android/SignInWithAppleButtonRenderer.kt @@ -0,0 +1,20 @@ +package com.nativephp.plugins.native_ui.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.nativephp.mobile.ui.nativerender.DefaultContainerNode +import com.nativephp.mobile.ui.nativerender.NativeUINode + +/** + * Sign in with Apple has no Android button, so this draws nothing unless the + * tag has children, which render as an Android-only fallback (laid out like a + * column). The iOS renderer draws Apple's button and ignores the children. + */ +object SignInWithAppleButtonRenderer { + @Composable + fun Render(node: NativeUINode, modifier: Modifier) { + if (node.children.isNotEmpty()) { + DefaultContainerNode(node, modifier) + } + } +} diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php index 299e903..1b65499 100644 --- a/resources/boost/guidelines/core.blade.php +++ b/resources/boost/guidelines/core.blade.php @@ -58,6 +58,14 @@ - In tests, drive pickers with the `pickDate()` / `pickTime()` / `pickDateTime()` / `clearPicker()` macros and assert with `assertPicker()` / `assertPickerValue()` / `assertPickerEmpty()`. +- For Sign in with Apple, use ``, never a `` with an Apple logo: + iOS draws Apple's own button, which is what App Review expects. `label` is + `sign-in` (default) / `continue` / `sign-up` (NOT free text), and + `button-style` is `auto` (default, black in light mode and white in dark) / + `black` / `white` / `white-outline`. The tap only calls the handler; start + the sign-in there with your auth plugin. Apple has no button on Android, so + nothing renders there unless you give the tag children as a fallback. @verbatim diff --git a/resources/ios/NativeUISignInWithAppleButtonRenderer.swift b/resources/ios/NativeUISignInWithAppleButtonRenderer.swift new file mode 100644 index 0000000..18bcf48 --- /dev/null +++ b/resources/ios/NativeUISignInWithAppleButtonRenderer.swift @@ -0,0 +1,209 @@ +import AuthenticationServices +import SwiftUI + +/// Apple's own Sign in with Apple button. A tap only fires `on_press`; the +/// app starts the sign-in itself (usually through a social auth plugin), so +/// this element never runs an `ASAuthorizationController`. +/// +/// Why UIKit's `ASAuthorizationAppleIDButton` and not SwiftUI's +/// `SignInWithAppleButton`: the SwiftUI button's only initialiser takes +/// `onRequest` and `onCompletion`, and every tap performs the Apple ID +/// request itself. It can't be used as a plain tap target, so a press would +/// start a second, competing request next to the app's. The UIKit button is +/// the same system-drawn control, with the same artwork, titles and styles. +/// +/// The UIKit button only draws. The tap goes through a SwiftUI `Button`, +/// which feeds its pressed state back so the system highlight still shows. +/// A UIKit target-action would never fire here: core's root view has tap +/// gestures for dismissing the keyboard, and when they recognise a tap they +/// cancel the touch in any UIKit control underneath. SwiftUI controls +/// compose with those gestures instead. +/// +/// Props: +/// - `label`: sign_in (default) | continue | sign_up +/// - `button_style`: auto (default) | black | white | white_outline. +/// `auto` follows the colour scheme: black in light mode, white in dark. +/// - `corner_radius`: points, clamped to half the height (a capsule). +/// The system default when absent. +/// - `disabled` +/// - `a11y_label`, `a11y_hint`: override the system's VoiceOver text. +/// +/// Height comes from the node's layout (`height`, `h-*`), 44pt when unset. +/// Width follows the parent's proposal like any other control (`w-full` +/// to fill), 280pt when nothing is proposed. Children are an Android-only +/// fallback and are ignored here. +struct NativeUISignInWithAppleButtonRenderer: View { + let node: NativeUINode + + @Environment(\.colorScheme) private var colorScheme + + static let defaultHeight: CGFloat = 44 + static let defaultWidth: CGFloat = 280 + + var body: some View { + let p = node.props + let pressCb = p.getCallbackId("on_press") + let nodeId = node.id + let type = Self.buttonType(p.getString("label", default: "sign_in")) + let a11yLabel = p.getString("a11y_label") + let a11yHint = p.getString("a11y_hint") + let disabled = p.getBool("disabled") + + Button { + guard pressCb != 0 else { return } + // Through the focus policy so a focused field's pending change + // reaches PHP before the press does, as for