Skip to content

Commit d350cb7

Browse files
authored
Merge pull request #62 from PureSwift/feature/label
2 parents 29356bc + 92b38d8 commit d350cb7

5 files changed

Lines changed: 168 additions & 1 deletion

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//
2+
// Label.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A title paired with an icon. Emitted as its own node (icon child, then title
6+
// child) rather than desugared to an HStack, so `labelStyle` can drop either
7+
// half — which it does through the same inherited-CompositionLocal mechanism the
8+
// control styles use.
9+
//
10+
11+
public struct Label<Title: View, Icon: View>: View {
12+
13+
internal let title: Title
14+
internal let icon: Icon
15+
16+
public init(@ViewBuilder title: () -> Title, @ViewBuilder icon: () -> Icon) {
17+
self.title = title()
18+
self.icon = icon()
19+
}
20+
21+
public typealias Body = Never
22+
}
23+
24+
public extension Label where Title == Text, Icon == Image {
25+
init<S: StringProtocol>(_ title: S, systemImage name: String) {
26+
self.init(title: { Text(title) }, icon: { Image(systemName: name) })
27+
}
28+
29+
init<S: StringProtocol>(_ title: S, image name: String) {
30+
self.init(title: { Text(title) }, icon: { Image(name) })
31+
}
32+
}
33+
34+
extension Label: PrimitiveView {
35+
public func _render(in context: ResolveContext) -> RenderNode {
36+
// icon first, title second — the interpreter relies on that order to
37+
// show or hide each half per the label style
38+
let iconNode = Evaluator.resolve(icon, context.descending("icon"))
39+
let titleNode = Evaluator.resolve(title, context.descending("title"))
40+
return RenderNode(type: "Label", id: context.path, children: [iconNode, titleNode])
41+
}
42+
}
43+
44+
// MARK: - Style
45+
46+
/// The built-in label styles. Like the control styles, this is the spelling
47+
/// only — the `LabelStyle` protocol for custom layouts isn't modeled.
48+
public struct _LabelStyle: Sendable {
49+
internal let kind: String
50+
public static let automatic = _LabelStyle(kind: "automatic")
51+
public static let titleAndIcon = _LabelStyle(kind: "titleAndIcon")
52+
public static let titleOnly = _LabelStyle(kind: "titleOnly")
53+
public static let iconOnly = _LabelStyle(kind: "iconOnly")
54+
}
55+
56+
public struct _LabelStyleModifier: RenderModifier {
57+
let style: _LabelStyle
58+
public var _modifierNode: ModifierNode {
59+
ModifierNode(kind: "labelStyle", args: ["style": .string(style.kind)])
60+
}
61+
}
62+
63+
public extension View {
64+
func labelStyle(_ style: _LabelStyle) -> ModifiedContent<Self, _LabelStyleModifier> {
65+
modifier(_LabelStyleModifier(style: style))
66+
}
67+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,43 @@ struct ModifierTests {
305305
#expect(node.children.first?.type == "HStack")
306306
}
307307

308+
@Test("Label emits its icon then its title")
309+
func label() {
310+
let node = ViewHost(Label("Favorites", systemImage: "star.fill")).evaluate()
311+
#expect(node.type == "Label")
312+
#expect(node.children.count == 2)
313+
// order matters: the interpreter shows/hides each half by position
314+
#expect(node.children[0].type == "Image")
315+
#expect(node.children[0].props["systemName"] == .string("star.fill"))
316+
#expect(firstTextString(node.children[1]) == "Favorites")
317+
}
318+
319+
@Test("Label takes an arbitrary title and icon")
320+
func labelCustom() {
321+
let node = ViewHost(
322+
Label(title: { Text("Hi") }, icon: { Text("!") })
323+
).evaluate()
324+
#expect(firstTextString(node.children[0]) == "!") // icon slot
325+
#expect(firstTextString(node.children[1]) == "Hi") // title slot
326+
}
327+
328+
@Test("labelStyle rides on the view, so it can be inherited")
329+
func labelStyle() {
330+
let node = ViewHost(
331+
VStack {
332+
Label("A", systemImage: "star")
333+
Label("B", systemImage: "star")
334+
}
335+
.labelStyle(.iconOnly)
336+
).evaluate()
337+
#expect(node.modifiers.first { $0.kind == "labelStyle" }?.args["style"] == .string("iconOnly"))
338+
// the container carries it; the labels themselves carry none
339+
for child in node.children {
340+
#expect(child.type == "Label")
341+
#expect(child.modifiers.first { $0.kind == "labelStyle" } == nil)
342+
}
343+
}
344+
308345
@Test("onAppear and onDisappear emit distinct callback kinds")
309346
func appearDisappear() {
310347
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ struct CatalogEntry: Identifiable {
4040
CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())),
4141
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
4242
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
43+
CatalogEntry(id: "label", title: "Label", screen: AnyCatalogScreen(LabelPlayground())),
4344
CatalogEntry(id: "image", title: "Images", screen: AnyCatalogScreen(ImagePlayground())),
4445
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
4546
CatalogEntry(id: "link", title: "Link", screen: AnyCatalogScreen(LinkPlayground())),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct LabelPlayground: View {
8+
var body: some View {
9+
ScrollView {
10+
VStack(alignment: .leading, spacing: 0) {
11+
Example("Label") {
12+
VStack(alignment: .leading, spacing: 10) {
13+
Label("Favorites", systemImage: "star.fill")
14+
Label("Delete", systemImage: "trash")
15+
Label("Mail", systemImage: "envelope")
16+
}
17+
}
18+
Example("labelStyle(.titleOnly)") {
19+
Label("Only the title", systemImage: "star.fill")
20+
.labelStyle(.titleOnly)
21+
}
22+
Example("labelStyle(.iconOnly)") {
23+
Label("Hidden title", systemImage: "star.fill")
24+
.labelStyle(.iconOnly)
25+
}
26+
Example("Inherited by a container") {
27+
// one style styles both labels
28+
VStack(alignment: .leading, spacing: 10) {
29+
Label("Inherited one", systemImage: "heart.fill")
30+
Label("Inherited two", systemImage: "heart.fill")
31+
}
32+
.labelStyle(.iconOnly)
33+
}
34+
}
35+
}
36+
.navigationTitle("Label")
37+
}
38+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ internal val LocalButtonStyle = compositionLocalOf { "automatic" }
207207
internal val LocalPickerStyle = compositionLocalOf { "automatic" }
208208
internal val LocalToggleStyle = compositionLocalOf { "automatic" }
209209
internal val LocalTextFieldStyle = compositionLocalOf { "automatic" }
210+
internal val LocalLabelStyle = compositionLocalOf { "automatic" }
210211

211212
/// Interprets a Swift-evaluated node tree into Material 3 composables.
212213
///
@@ -242,6 +243,7 @@ internal fun RenderChild(node: ViewNode) {
242243
var pickerStyle = LocalPickerStyle.current
243244
var toggleStyle = LocalToggleStyle.current
244245
var textFieldStyle = LocalTextFieldStyle.current
246+
var labelStyle = LocalLabelStyle.current
245247
for (m in node.modifiers) {
246248
when (m.kind) {
247249
"font" -> {
@@ -260,6 +262,7 @@ internal fun RenderChild(node: ViewNode) {
260262
"pickerStyle" -> m.args.string("style")?.let { pickerStyle = it }
261263
"toggleStyle" -> m.args.string("style")?.let { toggleStyle = it }
262264
"textFieldStyle" -> m.args.string("style")?.let { textFieldStyle = it }
265+
"labelStyle" -> m.args.string("style")?.let { labelStyle = it }
263266
}
264267
}
265268

@@ -274,6 +277,7 @@ internal fun RenderChild(node: ViewNode) {
274277
LocalPickerStyle provides pickerStyle,
275278
LocalToggleStyle provides toggleStyle,
276279
LocalTextFieldStyle provides textFieldStyle,
280+
LocalLabelStyle provides labelStyle,
277281
) { RenderResolved(node) }
278282
}
279283

@@ -343,6 +347,8 @@ private fun RenderResolved(node: ViewNode) {
343347
}
344348
}
345349

350+
"Label" -> RenderLabel(node)
351+
346352
"Link" -> RenderLink(node)
347353

348354
"GeometryReader" -> RenderGeometryReader(node)
@@ -601,6 +607,24 @@ private fun RenderLink(node: ViewNode) {
601607
}
602608
}
603609

610+
// children are [icon, title]; the inherited label style may drop either half.
611+
@Composable
612+
private fun RenderLabel(node: ViewNode) {
613+
val icon = node.children.getOrNull(0)
614+
val title = node.children.getOrNull(1)
615+
val style = LocalLabelStyle.current
616+
val showIcon = style != "titleOnly"
617+
val showTitle = style != "iconOnly"
618+
Row(
619+
verticalAlignment = Alignment.CenterVertically,
620+
horizontalArrangement = Arrangement.spacedBy(6.dp),
621+
modifier = node.composeModifiers(),
622+
) {
623+
if (showIcon) icon?.let { RenderChild(it) }
624+
if (showTitle) title?.let { RenderChild(it) }
625+
}
626+
}
627+
604628
private fun ViewNode.isPresentation(): Boolean =
605629
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
606630

@@ -1548,7 +1572,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
15481572
"transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle",
15491573
"buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle",
15501574
"accessibilityLabel", "accessibilityValue", "accessibilityHidden",
1551-
"accessibilityAddTraits", "accessibilityIdentifier",
1575+
"accessibilityAddTraits", "accessibilityIdentifier", "labelStyle",
15521576
)
15531577

15541578
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded

0 commit comments

Comments
 (0)