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
Original file line number Diff line number Diff line change
@@ -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<Title: View, Icon: View>: 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<S: StringProtocol>(_ title: S, systemImage name: String) {
self.init(title: { Text(title) }, icon: { Image(systemName: name) })
}

init<S: StringProtocol>(_ 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<Self, _LabelStyleModifier> {
modifier(_LabelStyleModifier(style: style))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
38 changes: 38 additions & 0 deletions Demo/App.swiftpm/Sources/LabelPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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" -> {
Expand All @@ -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 }
}
}

Expand All @@ -274,6 +277,7 @@ internal fun RenderChild(node: ViewNode) {
LocalPickerStyle provides pickerStyle,
LocalToggleStyle provides toggleStyle,
LocalTextFieldStyle provides textFieldStyle,
LocalLabelStyle provides labelStyle,
) { RenderResolved(node) }
}

Expand Down Expand Up @@ -343,6 +347,8 @@ private fun RenderResolved(node: ViewNode) {
}
}

"Label" -> RenderLabel(node)

"Link" -> RenderLink(node)

"GeometryReader" -> RenderGeometryReader(node)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
Loading