Skip to content

Commit 005804d

Browse files
committed
Interpret navigation, sheet, alert, and tab nodes
NavStack renders a Scaffold with a back-arrow app bar and animates between cached screens; sheets present as a ModalBottomSheet, alerts as an AlertDialog, both skipped by the normal child loop; TabView renders a NavigationBar and shows the tagged tab.
1 parent c845fd5 commit 005804d

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

  • Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,39 @@ import androidx.compose.foundation.layout.height
1515
import androidx.compose.foundation.layout.padding
1616
import androidx.compose.foundation.layout.size
1717
import androidx.compose.foundation.layout.width
18+
import androidx.compose.animation.AnimatedContent
19+
import androidx.compose.animation.togetherWith
20+
import androidx.compose.animation.slideInHorizontally
21+
import androidx.compose.animation.slideOutHorizontally
22+
import androidx.compose.foundation.layout.Column as LayoutColumn
23+
import androidx.compose.foundation.layout.fillMaxSize
24+
import androidx.compose.material3.AlertDialog
1825
import androidx.compose.material3.Button
1926
import androidx.compose.material3.CircularProgressIndicator
2027
import androidx.compose.material3.DropdownMenu
2128
import androidx.compose.material3.DropdownMenuItem
2229
import androidx.compose.material3.HorizontalDivider
2330
import androidx.compose.material3.LinearProgressIndicator
31+
import androidx.compose.material3.ExperimentalMaterial3Api
32+
import androidx.compose.material3.Icon
33+
import androidx.compose.material3.IconButton
34+
import androidx.compose.material3.ModalBottomSheet
35+
import androidx.compose.material3.NavigationBar
36+
import androidx.compose.material3.NavigationBarItem
2437
import androidx.compose.material3.OutlinedTextField
38+
import androidx.compose.material3.Scaffold
2539
import androidx.compose.material3.Slider
2640
import androidx.compose.material3.Switch
2741
import androidx.compose.material3.Text
2842
import androidx.compose.material3.TextButton
43+
import androidx.compose.material3.TopAppBar
44+
import androidx.compose.material.icons.Icons
45+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2946
import androidx.compose.runtime.Composable
47+
import androidx.compose.runtime.LaunchedEffect
3048
import androidx.compose.runtime.getValue
3149
import androidx.compose.runtime.key
50+
import androidx.compose.runtime.mutableStateMapOf
3251
import androidx.compose.runtime.mutableStateOf
3352
import androidx.compose.runtime.remember
3453
import androidx.compose.runtime.setValue
@@ -159,6 +178,15 @@ fun Render(node: ViewNode) {
159178

160179
"Picker" -> RenderPicker(node)
161180

181+
"NavStack" -> RenderNavStack(node)
182+
183+
"NavigationLink" -> TextButton(
184+
onClick = { node.long("onTap")?.let { SwiftBridge.sink.invokeVoid(it) } },
185+
modifier = node.composeModifiers(),
186+
) { RenderChildren(node) }
187+
188+
"TabView" -> RenderTabView(node)
189+
162190
"EmptyView" -> Unit
163191

164192
"Composable" -> ComposableRegistry.Render(node)
@@ -172,9 +200,14 @@ fun Render(node: ViewNode) {
172200
}
173201
}
174202

203+
// Sheets and alerts ride as hidden children; the presentation layer shows
204+
// them, so the normal child loop skips them.
205+
private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert"
206+
175207
@Composable
176208
private fun RenderChildren(node: ViewNode) {
177209
for (child in node.children) {
210+
if (child.isPresentation()) continue
178211
Render(child)
179212
}
180213
}
@@ -250,6 +283,7 @@ private fun pickerLabel(node: ViewNode): String {
250283
@Composable
251284
private fun ColumnScope.RenderColumnChildren(node: ViewNode) {
252285
for (child in node.children) {
286+
if (child.isPresentation()) continue
253287
if (child.type == "Spacer") {
254288
Spacer(modifier = Modifier.weight(1f))
255289
} else {
@@ -261,6 +295,7 @@ private fun ColumnScope.RenderColumnChildren(node: ViewNode) {
261295
@Composable
262296
private fun RowScope.RenderRowChildren(node: ViewNode) {
263297
for (child in node.children) {
298+
if (child.isPresentation()) continue
264299
if (child.type == "Spacer") {
265300
Spacer(modifier = Modifier.weight(1f))
266301
} else {
@@ -343,3 +378,134 @@ internal fun ViewNode.composeModifiers(): Modifier {
343378
}
344379
return modifier
345380
}
381+
382+
383+
@OptIn(ExperimentalMaterial3Api::class)
384+
@Composable
385+
private fun RenderNavStack(node: ViewNode) {
386+
val titles = node.stringArray("titles")
387+
val onPop = node.long("onPop")
388+
val depth = node.children.size
389+
// cache rendered screens by index so a popped screen can animate out
390+
val topIndex = depth - 1
391+
Scaffold(
392+
topBar = {
393+
val title = titles.getOrNull(topIndex).orEmpty()
394+
if (title.isNotEmpty() || depth > 1) {
395+
TopAppBar(
396+
title = { Text(title) },
397+
navigationIcon = {
398+
if (depth > 1) {
399+
IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) {
400+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
401+
}
402+
}
403+
},
404+
)
405+
}
406+
},
407+
) { padding ->
408+
AnimatedContent(
409+
targetState = topIndex,
410+
transitionSpec = {
411+
if (targetState > initialState) {
412+
(slideInHorizontally { it }) togetherWith (slideOutHorizontally { -it / 3 })
413+
} else {
414+
(slideInHorizontally { -it / 3 }) togetherWith (slideOutHorizontally { it })
415+
}
416+
},
417+
modifier = Modifier.fillMaxSize(),
418+
label = "nav",
419+
) { index ->
420+
LayoutColumn(modifier = Modifier.padding(padding)) {
421+
node.children.getOrNull(index)?.let { Render(it) }
422+
}
423+
}
424+
}
425+
RenderSheetsAndAlerts(node.children.getOrNull(topIndex))
426+
}
427+
428+
@Composable
429+
private fun RenderTabView(node: ViewNode) {
430+
val selection = node.long("selection")?.toInt() ?: 0
431+
val onSelect = node.long("onSelect")
432+
Scaffold(
433+
bottomBar = {
434+
NavigationBar {
435+
node.children.forEachIndexed { index, tab ->
436+
val label = tab.modifiers.firstOrNull { it.kind == "tabItem" }
437+
?.args?.let { (it["text"] as? kotlinx.serialization.json.JsonPrimitive)?.content } ?: ""
438+
val tag = tab.modifiers.firstOrNull { it.kind == "tag" }
439+
?.args?.let { (it["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() } ?: index
440+
NavigationBarItem(
441+
selected = tag == selection,
442+
onClick = { onSelect?.let { SwiftBridge.sink.invokeInt(it, tag) } },
443+
icon = {},
444+
label = { Text(label) },
445+
)
446+
}
447+
}
448+
},
449+
) { padding ->
450+
val current = node.children.firstOrNull { tab ->
451+
val tag = tab.modifiers.firstOrNull { it.kind == "tag" }
452+
?.args?.let { (it["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() }
453+
tag == selection
454+
} ?: node.children.getOrNull(selection)
455+
LayoutColumn(modifier = Modifier.padding(padding)) {
456+
current?.let { Render(it) }
457+
}
458+
}
459+
}
460+
461+
// Sheets and alerts ride as hidden children of a screen node; present them
462+
// when the screen carries the corresponding flag.
463+
@OptIn(ExperimentalMaterial3Api::class)
464+
@Composable
465+
private fun RenderSheetsAndAlerts(screen: ViewNode?) {
466+
if (screen == null) return
467+
for (child in screen.children) {
468+
when (child.type) {
469+
"Sheet" -> {
470+
val onDismiss = child.long("onDismiss")
471+
ModalBottomSheet(onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }) {
472+
child.children.firstOrNull()?.let { Render(it) }
473+
}
474+
}
475+
"Alert" -> RenderAlert(child)
476+
}
477+
}
478+
}
479+
480+
@Composable
481+
private fun RenderAlert(node: ViewNode) {
482+
val onDismiss = node.long("onDismiss")
483+
val buttons = (node.props["buttons"] as? kotlinx.serialization.json.JsonArray) ?: kotlinx.serialization.json.JsonArray(emptyList())
484+
val parsed = buttons.mapNotNull { entry ->
485+
val arr = entry as? kotlinx.serialization.json.JsonArray ?: return@mapNotNull null
486+
val title = (arr[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@mapNotNull null
487+
val id = (arr[2] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return@mapNotNull null
488+
title to id
489+
}
490+
AlertDialog(
491+
onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } },
492+
title = { Text(node.string("title") ?: "") },
493+
text = { node.string("message")?.let { Text(it) } },
494+
confirmButton = {
495+
parsed.firstOrNull()?.let { (title, id) ->
496+
TextButton(onClick = { SwiftBridge.sink.invokeVoid(id) }) { Text(title) }
497+
}
498+
},
499+
dismissButton = {
500+
if (parsed.size > 1) {
501+
val (title, id) = parsed[1]
502+
TextButton(onClick = { SwiftBridge.sink.invokeVoid(id) }) { Text(title) }
503+
}
504+
},
505+
)
506+
}
507+
508+
private fun ViewNode.stringArray(key: String): List<String> {
509+
val arr = props[key] as? kotlinx.serialization.json.JsonArray ?: return emptyList()
510+
return arr.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content }
511+
}

0 commit comments

Comments
 (0)