Merge pull request #2399 from vitorpamplona/claude/improve-zoom-animation-5csfK

Add shared element transition animation to image/video dialogs
This commit is contained in:
Vitor Pamplona
2026-04-15 09:38:32 -04:00
committed by GitHub
5 changed files with 206 additions and 34 deletions
@@ -28,6 +28,9 @@ import android.os.Looper
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -57,14 +60,23 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -94,6 +106,8 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
@@ -102,11 +116,62 @@ import net.engawapg.lib.zoomable.zoomable
fun ZoomableImageDialog( fun ZoomableImageDialog(
imageUrl: BaseMediaContent, imageUrl: BaseMediaContent,
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(), allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
sourceBounds: Rect? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
// Animation progress: 0f = at source position/size, 1f = fullscreen.
val progress = remember { Animatable(0f) }
var isExiting by remember { mutableStateOf(false) }
// Natural layout bounds of the currently-visible image/video inside the dialog.
// Used as the "target" of the grow animation so the image itself — not the dialog
// viewport — aligns with the tapped thumbnail at progress = 0.
var imageBounds by remember { mutableStateOf<Rect?>(null) }
// Start the enter animation as soon as valid image bounds are available. Without
// this gate, the animation can begin before onGloballyPositioned has reported real
// bounds and the graphicsLayer falls back to its alpha-only branch.
LaunchedEffect(Unit) {
snapshotFlow { imageBounds }
.filter { it != null && it.width > 0f && it.height > 0f }
.first()
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
)
}
LaunchedEffect(isExiting) {
if (isExiting) {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
)
onDismiss()
}
}
val dismissWithAnimation: () -> Unit = { if (!isExiting) isExiting = true }
val progressProvider: () -> Float = { progress.value }
// Accept the first set of valid bounds unconditionally. Subsequent updates are
// only accepted while the progress Animatable is idle, so a layout change
// mid-transition (e.g. an async image finishing loading, or a pager settling)
// can't re-target the transform and cause a hiccup.
val updateImageBounds: (Rect) -> Unit = { newBounds ->
if (newBounds.width > 0f && newBounds.height > 0f) {
val current = imageBounds
if (current == null) {
imageBounds = newBounds
} else if (!progress.isRunning && current != newBounds) {
imageBounds = newBounds
}
}
}
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = dismissWithAnimation,
properties = properties =
DialogProperties( DialogProperties(
usePlatformDefaultWidth = true, usePlatformDefaultWidth = true,
@@ -123,11 +188,31 @@ fun ZoomableImageDialog(
val attributes = WindowManager.LayoutParams() val attributes = WindowManager.LayoutParams()
attributes.copyFrom(activityWindow.attributes) attributes.copyFrom(activityWindow.attributes)
attributes.type = dialogWindow.attributes.type attributes.type = dialogWindow.attributes.type
// Disable the system dim so the thumbnail stays visible behind the growing dialog.
attributes.dimAmount = 0f
attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv()
dialogWindow.attributes = attributes dialogWindow.attributes = attributes
} }
Surface(Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
DialogContent(allImages, imageUrl, onDismiss, accountViewModel) // Background surface that fades in as the content grows to fullscreen.
Surface(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer { alpha = progressProvider() },
) {}
DialogContent(
allImages = allImages,
imageUrl = imageUrl,
sourceBounds = sourceBounds,
imageBounds = imageBounds,
onImageBoundsChanged = updateImageBounds,
progress = progressProvider,
onDismiss = dismissWithAnimation,
accountViewModel = accountViewModel,
)
} }
} }
} }
@@ -137,6 +222,10 @@ fun ZoomableImageDialog(
private fun DialogContent( private fun DialogContent(
allImages: ImmutableList<BaseMediaContent>, allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent, imageUrl: BaseMediaContent,
sourceBounds: Rect?,
imageBounds: Rect?,
onImageBoundsChanged: (Rect) -> Unit,
progress: () -> Float,
onDismiss: () -> Unit, onDismiss: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
@@ -178,19 +267,49 @@ private fun DialogContent(
}, },
), ),
Alignment.TopCenter, Alignment.TopCenter,
) {
// Transformed image/video container. Only this layer scales & translates so the
// image aligns with the tapped thumbnail on enter/exit. Controls stay put.
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
val src = sourceBounds
val img = imageBounds
if (src != null && img != null && img.width > 0f && img.height > 0f) {
transformOrigin = TransformOrigin(0f, 0f)
val startScaleX = src.width / img.width
val startScaleY = src.height / img.height
val p = progress()
scaleX = lerp(startScaleX, 1f, p)
scaleY = lerp(startScaleY, 1f, p)
translationX = lerp(src.left - img.left * startScaleX, 0f, p)
translationY = lerp(src.top - img.top * startScaleY, 0f, p)
} else {
// No source bounds: fall back to a plain fade.
alpha = progress()
}
},
) { ) {
if (allImages.size > 1) { if (allImages.size > 1) {
SlidingCarousel( SlidingCarousel(
pagerState = pagerState, pagerState = pagerState,
) { index -> ) { index ->
allImages.getOrNull(index)?.let { allImages.getOrNull(index)?.let { pageContent ->
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
RenderImageOrVideo( RenderImageOrVideo(
content = it, content = pageContent,
roundedCorner = false, roundedCorner = false,
isFiniteHeight = true, isFiniteHeight = true,
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onContentBoundsChanged =
if (index == pagerState.currentPage) {
onImageBoundsChanged
} else {
null
},
) )
} }
} }
@@ -203,14 +322,18 @@ private fun DialogContent(
isFiniteHeight = true, isFiniteHeight = true,
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onContentBoundsChanged = onImageBoundsChanged,
) )
} }
} }
}
AnimatedVisibility( AnimatedVisibility(
visible = controllerVisible.value, visible = controllerVisible.value,
enter = remember { fadeIn() }, enter = remember { fadeIn() },
exit = remember { fadeOut() }, exit = remember { fadeOut() },
// Also fade with the grow animation so controls appear/disappear alongside it.
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
) { ) {
Row( Row(
modifier = modifier =
@@ -373,6 +496,7 @@ private fun RenderImageOrVideo(
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onContentBoundsChanged: ((Rect) -> Unit)? = null,
) { ) {
val contentScale = val contentScale =
if (isFiniteHeight) { if (isFiniteHeight) {
@@ -381,7 +505,16 @@ private fun RenderImageOrVideo(
ContentScale.FillWidth ContentScale.FillWidth
} }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { val rowModifier =
if (onContentBoundsChanged != null) {
Modifier
.fillMaxWidth()
.onGloballyPositioned { onContentBoundsChanged(it.boundsInWindow()) }
} else {
Modifier.fillMaxWidth()
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) {
when (content) { when (content) {
is MediaUrlImage -> { is MediaUrlImage -> {
val mainModifier = val mainModifier =
@@ -58,8 +58,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -139,6 +142,11 @@ fun ZoomableContentView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
var dialogOpen by remember(content) { mutableStateOf(false) } var dialogOpen by remember(content) { mutableStateOf(false) }
var sourceBounds by remember(content) { mutableStateOf<Rect?>(null) }
val boundsTrackingModifier =
Modifier.onGloballyPositioned { coordinates ->
sourceBounds = coordinates.boundsInWindow()
}
when (content) { when (content) {
is MediaUrlImage -> { is MediaUrlImage -> {
@@ -147,6 +155,7 @@ fun ZoomableContentView(
val mainImageModifier = val mainImageModifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true } .clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
@@ -156,7 +165,10 @@ fun ZoomableContentView(
is MediaUrlVideo -> { is MediaUrlVideo -> {
SensitivityWarning(content.contentWarning, accountViewModel) { SensitivityWarning(content.contentWarning, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center,
) {
VideoView( VideoView(
videoUri = content.url, videoUri = content.url,
mimeType = content.mimeType, mimeType = content.mimeType,
@@ -180,6 +192,7 @@ fun ZoomableContentView(
val mainImageModifier = val mainImageModifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true } .clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
@@ -189,7 +202,10 @@ fun ZoomableContentView(
is MediaLocalVideo -> { is MediaLocalVideo -> {
content.localFile?.let { content.localFile?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
contentAlignment = Alignment.Center,
) {
VideoView( VideoView(
videoUri = it.toUri().toString(), videoUri = it.toUri().toString(),
mimeType = content.mimeType, mimeType = content.mimeType,
@@ -209,12 +225,13 @@ fun ZoomableContentView(
if (dialogOpen) { if (dialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
content, imageUrl = content,
images, allImages = images,
sourceBounds = sourceBounds,
onDismiss = { onDismiss = {
dialogOpen = false dialogOpen = false
}, },
accountViewModel, accountViewModel = accountViewModel,
) )
} }
} }
@@ -47,7 +47,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -101,6 +104,7 @@ fun RenderAppDefinition(
if (!theAppMetadata.banner.isNullOrBlank()) { if (!theAppMetadata.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
var bannerSourceBounds by remember { mutableStateOf<Rect?>(null) }
AsyncImage( AsyncImage(
model = theAppMetadata.banner, model = theAppMetadata.banner,
@@ -110,6 +114,7 @@ fun RenderAppDefinition(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(125.dp) .height(125.dp)
.onGloballyPositioned { bannerSourceBounds = it.boundsInWindow() }
.combinedClickable( .combinedClickable(
onClick = {}, onClick = {},
onLongClick = { onLongClick = {
@@ -125,6 +130,7 @@ fun RenderAppDefinition(
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!), imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
sourceBounds = bannerSourceBounds,
onDismiss = { zoomImageDialogOpen = false }, onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -153,6 +159,7 @@ fun RenderAppDefinition(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
var pictureSourceBounds by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.size(100.dp)) { Box(Modifier.size(100.dp)) {
theAppMetadata.picture?.let { picture -> theAppMetadata.picture?.let { picture ->
AsyncImage( AsyncImage(
@@ -168,6 +175,7 @@ fun RenderAppDefinition(
).clip(shape = CircleShape) ).clip(shape = CircleShape)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.onGloballyPositioned { pictureSourceBounds = it.boundsInWindow() }
.combinedClickable( .combinedClickable(
onClick = { zoomImageDialogOpen = true }, onClick = { zoomImageDialogOpen = true },
onLongClick = { onLongClick = {
@@ -181,6 +189,7 @@ fun RenderAppDefinition(
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!), imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
sourceBounds = pictureSourceBounds,
onDismiss = { zoomImageDialogOpen = false }, onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -32,7 +32,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@@ -68,6 +71,7 @@ fun DrawBanner(
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
AsyncImage( AsyncImage(
model = banner, model = banner,
@@ -79,6 +83,7 @@ fun DrawBanner(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(150.dp) .height(150.dp)
.onGloballyPositioned { sourceBounds = it.boundsInWindow() }
.combinedClickable( .combinedClickable(
onClick = { zoomImageDialogOpen = true }, onClick = { zoomImageDialogOpen = true },
onLongClick = { onLongClick = {
@@ -92,6 +97,7 @@ fun DrawBanner(
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(banner), imageUrl = RichTextParser.parseImageOrVideo(banner),
sourceBounds = sourceBounds,
onDismiss = { zoomImageDialogOpen = false }, onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -51,6 +51,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -173,12 +176,15 @@ fun ZoomableUserPicture(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var zoomImageUrl by remember { mutableStateOf<String?>(null) } var zoomImageUrl by remember { mutableStateOf<String?>(null) }
var sourceBounds by remember { mutableStateOf<Rect?>(null) }
ClickableUserPicture( ClickableUserPicture(
baseUser = baseUser, baseUser = baseUser,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
size = size, size = size,
modifier = MaterialTheme.colorScheme.userProfileBorderModifier, modifier =
MaterialTheme.colorScheme.userProfileBorderModifier
.onGloballyPositioned { sourceBounds = it.boundsInWindow() },
onClick = { onClick = {
val pic = baseUser.profilePicture() val pic = baseUser.profilePicture()
if (pic != null) { if (pic != null) {
@@ -197,7 +203,8 @@ fun ZoomableUserPicture(
zoomImageUrl?.let { zoomImageUrl?.let {
ZoomableImageDialog( ZoomableImageDialog(
RichTextParser.parseImageOrVideo(it), imageUrl = RichTextParser.parseImageOrVideo(it),
sourceBounds = sourceBounds,
onDismiss = { zoomImageUrl = null }, onDismiss = { zoomImageUrl = null },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )