diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Label.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Label.swift new file mode 100644 index 0000000..396507f --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Label.swift @@ -0,0 +1,67 @@ +// +// Label.swift +// AndroidSwiftUICore +// +// A title paired with an icon. Emitted as its own node (icon child, then title +// child) rather than desugared to an HStack, so `labelStyle` can drop either +// half — which it does through the same inherited-CompositionLocal mechanism the +// control styles use. +// + +public struct Label: View { + + internal let title: Title + internal let icon: Icon + + public init(@ViewBuilder title: () -> Title, @ViewBuilder icon: () -> Icon) { + self.title = title() + self.icon = icon() + } + + public typealias Body = Never +} + +public extension Label where Title == Text, Icon == Image { + init(_ title: S, systemImage name: String) { + self.init(title: { Text(title) }, icon: { Image(systemName: name) }) + } + + init(_ title: S, image name: String) { + self.init(title: { Text(title) }, icon: { Image(name) }) + } +} + +extension Label: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + // icon first, title second — the interpreter relies on that order to + // show or hide each half per the label style + let iconNode = Evaluator.resolve(icon, context.descending("icon")) + let titleNode = Evaluator.resolve(title, context.descending("title")) + return RenderNode(type: "Label", id: context.path, children: [iconNode, titleNode]) + } +} + +// MARK: - Style + +/// The built-in label styles. Like the control styles, this is the spelling +/// only — the `LabelStyle` protocol for custom layouts isn't modeled. +public struct _LabelStyle: Sendable { + internal let kind: String + public static let automatic = _LabelStyle(kind: "automatic") + public static let titleAndIcon = _LabelStyle(kind: "titleAndIcon") + public static let titleOnly = _LabelStyle(kind: "titleOnly") + public static let iconOnly = _LabelStyle(kind: "iconOnly") +} + +public struct _LabelStyleModifier: RenderModifier { + let style: _LabelStyle + public var _modifierNode: ModifierNode { + ModifierNode(kind: "labelStyle", args: ["style": .string(style.kind)]) + } +} + +public extension View { + func labelStyle(_ style: _LabelStyle) -> ModifiedContent { + modifier(_LabelStyleModifier(style: style)) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 0c6789d..35fd013 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -305,6 +305,43 @@ struct ModifierTests { #expect(node.children.first?.type == "HStack") } + @Test("Label emits its icon then its title") + func label() { + let node = ViewHost(Label("Favorites", systemImage: "star.fill")).evaluate() + #expect(node.type == "Label") + #expect(node.children.count == 2) + // order matters: the interpreter shows/hides each half by position + #expect(node.children[0].type == "Image") + #expect(node.children[0].props["systemName"] == .string("star.fill")) + #expect(firstTextString(node.children[1]) == "Favorites") + } + + @Test("Label takes an arbitrary title and icon") + func labelCustom() { + let node = ViewHost( + Label(title: { Text("Hi") }, icon: { Text("!") }) + ).evaluate() + #expect(firstTextString(node.children[0]) == "!") // icon slot + #expect(firstTextString(node.children[1]) == "Hi") // title slot + } + + @Test("labelStyle rides on the view, so it can be inherited") + func labelStyle() { + let node = ViewHost( + VStack { + Label("A", systemImage: "star") + Label("B", systemImage: "star") + } + .labelStyle(.iconOnly) + ).evaluate() + #expect(node.modifiers.first { $0.kind == "labelStyle" }?.args["style"] == .string("iconOnly")) + // the container carries it; the labels themselves carry none + for child in node.children { + #expect(child.type == "Label") + #expect(child.modifiers.first { $0.kind == "labelStyle" } == nil) + } + } + @Test("onAppear and onDisappear emit distinct callback kinds") func appearDisappear() { let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate() diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 3f5ecd1..f8dd3f1 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -40,6 +40,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())), CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())), CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())), + CatalogEntry(id: "label", title: "Label", screen: AnyCatalogScreen(LabelPlayground())), CatalogEntry(id: "image", title: "Images", screen: AnyCatalogScreen(ImagePlayground())), CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())), CatalogEntry(id: "link", title: "Link", screen: AnyCatalogScreen(LinkPlayground())), diff --git a/Demo/App.swiftpm/Sources/LabelPlaygrounds.swift b/Demo/App.swiftpm/Sources/LabelPlaygrounds.swift new file mode 100644 index 0000000..cecc463 --- /dev/null +++ b/Demo/App.swiftpm/Sources/LabelPlaygrounds.swift @@ -0,0 +1,38 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct LabelPlayground: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Label") { + VStack(alignment: .leading, spacing: 10) { + Label("Favorites", systemImage: "star.fill") + Label("Delete", systemImage: "trash") + Label("Mail", systemImage: "envelope") + } + } + Example("labelStyle(.titleOnly)") { + Label("Only the title", systemImage: "star.fill") + .labelStyle(.titleOnly) + } + Example("labelStyle(.iconOnly)") { + Label("Hidden title", systemImage: "star.fill") + .labelStyle(.iconOnly) + } + Example("Inherited by a container") { + // one style styles both labels + VStack(alignment: .leading, spacing: 10) { + Label("Inherited one", systemImage: "heart.fill") + Label("Inherited two", systemImage: "heart.fill") + } + .labelStyle(.iconOnly) + } + } + } + .navigationTitle("Label") + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 3498913..60a124d 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -207,6 +207,7 @@ internal val LocalButtonStyle = compositionLocalOf { "automatic" } internal val LocalPickerStyle = compositionLocalOf { "automatic" } internal val LocalToggleStyle = compositionLocalOf { "automatic" } internal val LocalTextFieldStyle = compositionLocalOf { "automatic" } +internal val LocalLabelStyle = compositionLocalOf { "automatic" } /// Interprets a Swift-evaluated node tree into Material 3 composables. /// @@ -242,6 +243,7 @@ internal fun RenderChild(node: ViewNode) { var pickerStyle = LocalPickerStyle.current var toggleStyle = LocalToggleStyle.current var textFieldStyle = LocalTextFieldStyle.current + var labelStyle = LocalLabelStyle.current for (m in node.modifiers) { when (m.kind) { "font" -> { @@ -260,6 +262,7 @@ internal fun RenderChild(node: ViewNode) { "pickerStyle" -> m.args.string("style")?.let { pickerStyle = it } "toggleStyle" -> m.args.string("style")?.let { toggleStyle = it } "textFieldStyle" -> m.args.string("style")?.let { textFieldStyle = it } + "labelStyle" -> m.args.string("style")?.let { labelStyle = it } } } @@ -274,6 +277,7 @@ internal fun RenderChild(node: ViewNode) { LocalPickerStyle provides pickerStyle, LocalToggleStyle provides toggleStyle, LocalTextFieldStyle provides textFieldStyle, + LocalLabelStyle provides labelStyle, ) { RenderResolved(node) } } @@ -343,6 +347,8 @@ private fun RenderResolved(node: ViewNode) { } } + "Label" -> RenderLabel(node) + "Link" -> RenderLink(node) "GeometryReader" -> RenderGeometryReader(node) @@ -601,6 +607,24 @@ private fun RenderLink(node: ViewNode) { } } +// children are [icon, title]; the inherited label style may drop either half. +@Composable +private fun RenderLabel(node: ViewNode) { + val icon = node.children.getOrNull(0) + val title = node.children.getOrNull(1) + val style = LocalLabelStyle.current + val showIcon = style != "titleOnly" + val showTitle = style != "iconOnly" + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = node.composeModifiers(), + ) { + if (showIcon) icon?.let { RenderChild(it) } + if (showTitle) title?.let { RenderChild(it) } + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem" @@ -1548,7 +1572,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle", "buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle", "accessibilityLabel", "accessibilityValue", "accessibilityHidden", - "accessibilityAddTraits", "accessibilityIdentifier", + "accessibilityAddTraits", "accessibilityIdentifier", "labelStyle", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded