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 PMmovableContentOf
Pablo
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 RouteMapPanel
Alex 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 PMmovableContentOf
Alex 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