Pablo
03/31/2025, 6:10 PMPablo
03/31/2025, 6:11 PM@Composable
fun ExampleScreen(isPortrait: Boolean) {
@Composable
fun LocationFields() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
LocationField(
label = "Origin",
value = "originValue",
updateValue = { /* Update origin logic */ },
acceptValue = { /* Search origin logic */ }
)
LocationField(
label = "Destination",
value = "destinationValue",
updateValue = { /* Update destination logic */ },
acceptValue = { /* Search destination logic */ }
)
}
}
@Composable
fun MapPanel() {
RouteMapPanel(
centerLocation = "centerLocation",
onUpdateCenter = { /* Update map center logic */ },
onSelectLocation = { /* Select location logic */ },
onNavigate = { /* Navigation logic */ },
onSwitch = { /* Switch locations logic */ }
)
}
if (isPortrait) {
Column(
modifier = Modifier.fillMaxSize()
) {
LocationFields()
MapPanel()
}
} else {
Row(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxWidth(0.4f)
.padding(8.dp)
) {
LocationFields()
}
MapPanel()
}
}
}Pablo
03/31/2025, 6:11 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 6:27 PMmovableContentOf to keep composable state between the two calls to MapPanel when the orientation changesPablo
03/31/2025, 6:51 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 6:55 PMPablo
03/31/2025, 8:14 PMmovableContentOfPablo
03/31/2025, 8:15 PMPablo
03/31/2025, 8:15 PMPablo
03/31/2025, 8:16 PMval movableFieldsContent = remember {
movableContentOf { singleLineFields: Boolean ->
and this:
if (portrait) {
Column(modifier = Modifier.fillMaxSize()) {
movableFieldsContent(singleLineFields = true)
movableMapContent()
}
} else {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth(0.4f).padding(6.dp)) {
movableFieldsContent(singleLineFields = false)
}
movableMapContent()
}
}Zach Klippenstein (he/him) [MOD]
03/31/2025, 8:17 PMPablo
03/31/2025, 8:17 PMPablo
03/31/2025, 8:18 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:18 PMPablo
03/31/2025, 8:18 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:18 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:18 PMctrl+j on the function in StudioPablo
03/31/2025, 8:19 PMPablo
03/31/2025, 8:20 PMPablo
03/31/2025, 8:21 PMmovableFieldsContent(false)
but I can't name it like this:
movableFieldsContent(singleLineFields = false)Zach Klippenstein (he/him) [MOD]
03/31/2025, 8:22 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:22 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:23 PMPablo
03/31/2025, 8:25 PMPablo
03/31/2025, 8:25 PMPablo
03/31/2025, 8:25 PMPablo
03/31/2025, 8:25 PMPablo
03/31/2025, 8:25 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:50 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 8:51 PMPablo
03/31/2025, 8:59 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 9:00 PMmovableContentOf actually does though before just using it everywhere. It does a very specific thingPablo
03/31/2025, 9:02 PMPablo
03/31/2025, 9:02 PMPablo
03/31/2025, 9:02 PMPablo
03/31/2025, 9:03 PMPablo
03/31/2025, 9:03 PMval movableMapContent = remember {
movableContentOf {
RouteMapPanel(Pablo
03/31/2025, 9:03 PMval movableContent = remember(content) { movableContentOf(content) }Pablo
03/31/2025, 9:04 PMZach Klippenstein (he/him) [MOD]
03/31/2025, 9:04 PMPablo
03/31/2025, 9:05 PMAlex Vanyo
03/31/2025, 9:35 PMremember with remember { movableContentOf { } } - because the lambda passed to remember will only run once, you may end up capturing things inside the movableContentOf call that are arguments to RouteMapPanelAlex Vanyo
03/31/2025, 10:04 PMmovableContentOf is something like this:
val currentContent by rememberUpdatedState @Composable {
RouteMapPanel()
}
val movableContent = remember { movableContentOf { currentContent() } }
It allows having a single movable slot of content, but the rememberUpdatedState means that you can arbitrarily change what is rendered within that movable slot without worrying about capturing things accidentally since the only thing that is captured is the delegate that is being updated in composition.Alex Vanyo
03/31/2025, 10:05 PMmovableContentOfAlex Vanyo
03/31/2025, 10:25 PMmovableContentOf - I’d confirm if the decision point between portrait and landscape is the UX you really want to have for changing the screen layout, or if a certain amount of available window width or height would be more appropriate.
Using portrait vs landscape can lead to some surprising behavior: making a window taller by increasing the height can switch from a landscape window to a portrait window - if that results in a different layout, you can end up in the confusing situation where giving the app more space to render resulted in less information being displayed.
Window size classes are based on available width and available height instead of orientation, so decisions based on those don’t run into that issue.Pablo
04/01/2025, 6:33 AMval portrait = LocalConfiguration.current.screenWidthDp < LocalConfiguration.current.screenHeightDp
And if the result of portrait is false, I produced my "landscape" screen structure with the fields on the left of the map.
Why I did this? because I didn't found which aspect ratio determines that expanded window size, and checking if LocalConfiguration.current.screenWidthDp < LocalConfiguration.current.screenHeightDp worked in my tests with a tablet and a resizable dual window with two apps test. When I resized the app until that if changed to true, the content moved to the portrait structure. What do you think about doing that?Pablo
04/01/2025, 8:44 AMmovableContentOf call that are arguments to `RouteMapPanel`"Pablo
04/01/2025, 8:45 AM// I store the map call in a movableContentOf to remember it between orientation changes
val movableMapContent = remember {
movableContentOf {
RouteMapPanel(
simpleMapCenterLocation = uiState.simpleMapCenterLocation,
selectedLocation = uiState.selectedLocation,
selectedLocationAddress = uiState.selectedLocationAddress,
originLocation = uiState.originLocation,
destinationLocation = uiState.destinationLocation,
hasLocationPermission = uiState.hasLocationPermission,
updateSimpleMapCenterLocation = { vm.updateSimpleMapCenterLocation(it) },
updateSelectedLocation = { vm.updateSelectedLocation(it) },
onOriginSelected = {
vm.updateSelectedLocation(null)
vm.transformLatLngIntoAddress(it, AddressType.ORIGIN)
},
onDestinationSelected = {
vm.updateSelectedLocation(null)
vm.transformLatLngIntoAddress(it, AddressType.DESTINATION)
},
onNavigate = { vm.navigate(context) },
onSwitchAddresses = { vm.switchAddresses() }
)
}
}
Do you see any issue there? what do you mean with that some arguments will be captured?Alex Vanyo
04/01/2025, 9:40 PMuiState and vm will be captured in that setup - it might be work fine depending on exactly how uiState and vm are declared and if they can change.
A simpler example where you can see the difference in behavior is this:
@Composable
@Preview
fun Repro() {
var count by rememberSaveable { mutableStateOf(0) }
MyButton(count, onClick = { count++ })
}
@Composable
fun MyButton(
count: Int,
onClick: () -> Unit,
) {
val movableContent = remember {
movableContentOf {
Button(
onClick = onClick,
) {
Text("count: $count")
}
}
}
movableContent()
}
This won’t update the text in the button, because count is being captured in the remember from the first composition of MyButton (onClick is also being captured)
But this one will work:
@Composable
@Preview
fun Repro() {
var count by rememberSaveable {
mutableStateOf(0)
}
val movableContent = remember {
movableContentOf {
Button(
onClick = { count++ }
) {
Text("count: $count")
}
}
}
movableContent()
}
Something is still being captured here, but now it’s the delegate for count that is being captured, not value of count itself.
That allows fixing the setup for the first one by using rememberUpdatedState :
@Composable
@Preview
fun Repro() {
var count by rememberSaveable { mutableStateOf(0) }
MyButton(count, onClick = { count++ })
}
@Composable
fun MyButton(
count: Int,
onClick: () -> Unit,
) {
val currentContent by rememberUpdatedState @Composable {
Button(
onClick = onClick,
) {
Text("count: $count")
}
}
val movableContent = remember {
movableContentOf { currentContent() }
}
movableContent()
}Alex Vanyo
04/01/2025, 9:43 PMuiState is already defined with a delegate, and if vm doesn’t change.
But if you later add a parameter that is being passed through like MyButton, or uiState isn’t a delegate, you can get into a lot of headaches with capturing that are really hard to debugAlex Vanyo
04/01/2025, 9:50 PMmovableContentOf is especially tricky because the normal rule of adding keys to remember for anything used inside of it often defeats the purpose of movableContentOf - the whole point is to try to keep that internal state around without throwing it away, and recreating the movableContentOf will throw away that internal statePablo
04/02/2025, 9:52 AMPablo
04/02/2025, 9:55 AMPablo
04/02/2025, 9:57 AM@Composable
fun ExampleScreen(isPortrait: Boolean) {
@Composable
fun LocationFields() {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
LocationField(
label = "Origin",
value = "originValue",
updateValue = { /* Update origin logic */ },
acceptValue = { /* Search origin logic */ }
)
LocationField(
label = "Destination",
value = "destinationValue",
updateValue = { /* Update destination logic */ },
acceptValue = { /* Search destination logic */ }
)
}
}
@Composable
fun MapPanel() {
RouteMapPanel(
centerLocation = "centerLocation",
onUpdateCenter = { /* Update map center logic */ },
onSelectLocation = { /* Select location logic */ },
onNavigate = { /* Navigation logic */ },
onSwitch = { /* Switch locations logic */ }
)
}
if (isPortrait) {
Column(modifier = Modifier.fillMaxSize()) {
LocationFields()
MapPanel()
}
} else {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth(0.4f).padding(8.dp)) {
LocationFields()
}
MapPanel()
}
}
}Pablo
04/02/2025, 9:57 AMPablo
04/03/2025, 7:41 AMAlex Vanyo
04/04/2025, 4:30 PM@Composable function calls are totally fine themselves, the downside of not using movableContentOf would be loss of state of content within MapPanel or LocationFields
When the call hierarchy changes for where MapPanel is invoked, all of the state within MapPanel will be lost as the new MapPanel call is effectively a fresh "instance".
So if MapPanel has internal state like the current lat-long of where the user is looking at, that will be lost if the orientation changes - which is definitely a bug from the user perspective if they rotate their device and the state of where they were looking at is gone.Pablo
04/06/2025, 7:26 PMPablo
04/06/2025, 7:28 PM