Ostkontentitan
Ostkontentitan

Reputation: 7020

Offset a wide image for horizontal parallax effect in Android Compose

I am trying to create a parallax effect with a wide image lets say: https://placekitten.com/2000/400

On top of it i show a LazyRow with items. Whilst the user goes through those i would like to offset the image so that it 'moves along' slowly with the items.

The image should basically FillHeight and align to the Start so that it can move left to right.

The calculation part of the offset is done and works as it should. So does overlaying the lazy row. Now displaying the image properly is where i struggle.

I tried variations of this:

Image(
  modifier = Modifier
    .height(BG_IMAGE_HEIGHT)
    .graphicsLayer {
      translationX = -parallaxOffset
    },
  painter = painter,
  contentDescription = "",
  alignment = Alignment.CenterStart,
  contentScale = ContentScale.FillHeight
)

Unfortunately though the rendered image is chopped off at the end of the initially visible portion so when the image moves there is just empty space coming up.

DEMO

Demo in the middle of scrolling

As you can see while going through the list white space appears on the right instead of the remaining image.

How do i do this properly?

Upvotes: 2

Views: 962

Answers (3)

nglauber
nglauber

Reputation: 24044

I'm leaving my solution here...

@Composable
private fun ListBg(
    firstVisibleIndex: Int,
    totalVisibleItems: Int,
    firstVisibleItemOffset: Int,
    itemsCount: Int,
    itemWidth: Dp,
    maxWidth: Dp
) {
    val density = LocalDensity.current
    val firstItemOffsetDp = with(density) { firstVisibleItemOffset.toDp() }
    val hasNoScroll = itemsCount <= totalVisibleItems
    val totalWidth = if (hasNoScroll) maxWidth else maxWidth * 2
    val scrollableBgWidth = if (hasNoScroll) maxWidth else totalWidth - maxWidth
    val scrollStep = scrollableBgWidth / itemsCount
    val firstVisibleScrollPercentage = firstItemOffsetDp.value / itemWidth.value
    val xOffset =
        if (hasNoScroll) 0.dp else -(scrollStep * firstVisibleIndex) - (scrollStep * firstVisibleScrollPercentage)
    Box(
        Modifier
            .wrapContentWidth(unbounded = true, align = Alignment.Start)
            .offset { IntOffset(x = xOffset.roundToPx(), y = 0) }
    ) {
        Image(
            painter = rememberAsyncImagePainter(
                model = "https://placekitten.com/2000/400",
                contentScale = ContentScale.FillWidth,
            ),
            contentDescription = null,
            alignment = Alignment.TopCenter,
            modifier = Modifier
                .height(232.dp)
                .width(totalWidth)
        )
    }
}

@Composable
fun ListWithParallaxImageScreen() {
    val lazyListState = rememberLazyListState()
    val firstVisibleIndex by remember {
        derivedStateOf {
            lazyListState.firstVisibleItemIndex
        }
    }
    val totalVisibleItems by remember {
        derivedStateOf {
            lazyListState.layoutInfo.visibleItemsInfo.size
        }
    }
    val firstVisibleItemOffset by remember {
        derivedStateOf {
            lazyListState.firstVisibleItemScrollOffset
        }
    }
    val itemsCount = 10
    val itemWidth = 300.dp
    val itemPadding = 16.dp
    BoxWithConstraints(Modifier.fillMaxSize()) {
        ListBg(
            firstVisibleIndex,
            totalVisibleItems,
            firstVisibleItemOffset,
            itemsCount,
            itemWidth + (itemPadding * 2),
            maxWidth
        )
        LazyRow(state = lazyListState, modifier = Modifier.fillMaxSize()) {
            items(itemsCount) {
                Card(
                    backgroundColor = Color.LightGray.copy(alpha = .5f),
                    modifier = Modifier
                        .padding(itemPadding)
                        .width(itemWidth)
                        .height(200.dp)
                ) {
                    Text(
                        text = "Item $it",
                        Modifier
                            .padding(horizontal = 16.dp, vertical = 6.dp)
                    )
                }
            }
        }
    }
}

Here is the result:

enter image description here

Upvotes: 1

Thracian
Thracian

Reputation: 67443

You can do it by drawing image to Canvas and setting srcOffset to set which section of the image should be drawn and dstOffset to where it should be drawn in canvas of drawImage function

@Composable
private fun MyComposable() {
    Column {
        var parallaxOffset by remember { mutableStateOf(0f) }

        Spacer(modifier = Modifier.height(100.dp))
        Slider(
            value = parallaxOffset, onValueChange = {
                parallaxOffset = it
            },
            valueRange = 0f..1500f
        )

        val imageBitmap = ImageBitmap.imageResource(id = R.drawable.kitty)
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                .border(2.dp, Color.Red)
        ) {
            val canvasWidth = size.width.toInt()
            val canvasHeight = size.height.toInt()

            val imageHeight = imageBitmap.height
            val imageWidth = imageBitmap.width

            drawImage(
                image = imageBitmap,
                srcOffset = IntOffset(
                    parallaxOffset.toInt().coerceAtMost(kotlin.math.abs(canvasWidth - imageWidth)),
                    0
                ),
                dstOffset = IntOffset(0, kotlin.math.abs(imageHeight - canvasHeight) /2)
            )

        }
    }
}

Result

enter image description here

Upvotes: 2

Phil Dukhov
Phil Dukhov

Reputation: 88457

Image is too smart and doesn't draw anything beyond the bounds. translationX doesn't change the bound but only moves the view.

Here's how you can draw it manually:

val painter = painterResource(id = R.drawable.my_image_1)
Canvas(
    modifier = Modifier
        .fillMaxWidth()
        .height(BG_IMAGE_HEIGHT)
) {
    translate(
        left = -parallaxOffset,
    ) {
        with(painter) {
            draw(Size(width = painter.intrinsicSize.aspectRatio * size.height, height = size.height))
        }
    }
}

I don't see your code that calculates parallaxOffset, but just in case, I suggest you watch this video to get the best performance.

Upvotes: 2

Related Questions