@@ -15,20 +15,39 @@ import androidx.compose.foundation.layout.height
1515import androidx.compose.foundation.layout.padding
1616import androidx.compose.foundation.layout.size
1717import 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
1825import androidx.compose.material3.Button
1926import androidx.compose.material3.CircularProgressIndicator
2027import androidx.compose.material3.DropdownMenu
2128import androidx.compose.material3.DropdownMenuItem
2229import androidx.compose.material3.HorizontalDivider
2330import 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
2437import androidx.compose.material3.OutlinedTextField
38+ import androidx.compose.material3.Scaffold
2539import androidx.compose.material3.Slider
2640import androidx.compose.material3.Switch
2741import androidx.compose.material3.Text
2842import 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
2946import androidx.compose.runtime.Composable
47+ import androidx.compose.runtime.LaunchedEffect
3048import androidx.compose.runtime.getValue
3149import androidx.compose.runtime.key
50+ import androidx.compose.runtime.mutableStateMapOf
3251import androidx.compose.runtime.mutableStateOf
3352import androidx.compose.runtime.remember
3453import 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
176208private 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
251284private 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
262296private 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