[Compose UI] Cutstom Pager를 이용하여 swipe transition 구현하기

Swipe Transition?

앱을 사용하다보면,  다음과 같이 swipe를 할 때 애니메이션을 사용하는 경우가 있다.

 

이러한 기능을 어떻게 구현할 수 있을까?

오늘 구현 결과

Pager

콘텐츠를 좌측에서 우측으로 넘기고자 하면, Compose에서는 HorizontalPager 또는 VerticalPager를 사용하면 된다.

 

이번 포스팅에서는 HorizontalPager를 이용하여, 해당 애니메이션을 적용한 View를 구현해보겠다.

 

아래의 포스팅을 참고하여 제작하였으므로, 더 자세한 내용을 원하는 분들은 확인!-!

https://medium.com/androiddevelopers/customizing-compose-pager-with-fun-indicators-and-transitions-12b3b69af2cc

 

Customizing Compose Pager with fun indicators and transitions 🚥

The Compose March 2023 release introduces HorizontalPager and VerticalPager. Let’s look at creating some fun indicators and transitions.

medium.com

dependencies

가장 먼저 의존성을 추가해주고 시작하겠습니당.

implementation("com.google.accompanist:accompanist-pager:0.24.3-alpha")
implementation("com.google.accompanist:accompanist-pager-indicators:0.24.3-alpha")
implementation("com.google.accompanist:accompanist-coil:0.13.0")

implementation("io.coil-kt:coil-compose:2.5.0")

Random Image

일단 랜덤 이미지를 가져오는 함수를 작성해보겠다.

 

"https://picsum.photos/seed/$seed/$width/$height"

위의 Url은 랜덤 seed 값에 따라 랜덤 사진을 보여주는 링크이다. 해당 링크를 이용하여 random값을 생성하여 사진을 생성해주겠다.

 

private fun randomSampleImageUrl(
    seed: Int = rangeForRandom.random(),
    width: Int = 300,
    height: Int = width,
): String {
    return "https://picsum.photos/seed/$seed/$width/$height"
}
@Composable
fun rememberRandomSampleImageUrl(
    seed: Int = rangeForRandom.random(),
    width: Int = 300,
    height: Int = width,
): String = remember { randomSampleImageUrl(seed, width, height) }

 

 

Custom Pager

@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CustomPager() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFECECEC))
    ) {
        val pagerState = rememberPagerState(pageCount = { 10 })
        HorizontalPager(
            pageSpacing = 16.dp,
            beyondBoundsPageCount = 2,
            state = pagerState,
            modifier = Modifier.fillMaxSize()
        ) { page ->
            Box(modifier = Modifier.fillMaxSize()) {
                WeatherInformationCard(
                    modifier = Modifier
                        .padding(32.dp)
                        .align(Alignment.Center),
                    pagerState = pagerState,
                    page = page
                )
            }
        }
    }
}

 

HorizonPager의 경우에는 실험적인 API이므로, 아래의 코드가 필요하다.

@OptIn(ExperimentalFoundationApi::class)

 

현재 페이지의 상태를 저장하기 위해 rememberPagerState를 생성해주었다.

페이지간의 간격을 16dp로 설정하고 beyondBoundPage를 통해 뷰포트를 벗어나는 페이지를 설정해준다.

 

그리고 각각의 페이지에 보여줄 내용을 WeatherInfomationCard에 작성해주었다.

 

Card

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun WeatherInformationCard(
    pagerState: PagerState,
    page: Int,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
    ) {
        Column(modifier = Modifier) {
            val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
            Image(
                painter = rememberAsyncImagePainter(model = rememberRandomSampleImageUrl(width = 1200)),
                contentDescription = null,
                modifier = Modifier
                    .graphicsLayer {
                        val scale = lerp(1f, 1.75f, pageOffset)
                        scaleX = scale
                        scaleY = scale
                    },
            )
        }
    }
}

 

페이지 각각의 세부적 내용은 다음과 같다 여기서 설명할 부분은 rememberAsyncImagePainter이다.

Image 컴포저블을 이용하여 이미지를 보여주기 위해, rememberAsyncImagePainter을 이용하여 이미지를 비동기적으로 불러와준다.

 

graphicsLayer의 경우에는, 이미지에 스케일을 적용하는 애니메이션을 사용하기 위해 작성했다. 

여기서 lerp의 경우애는, 

private fun lerp(start: Float, stop: Float, fraction: Float): Float {
    return (1 - fraction) * start + fraction * stop
}

시작 값과 끝깞의 중간 값을 선형 보간을 계산해준다. 결국에는 사용자가 swipe를 하게될 때 이미지의 크기가 작아지거나 커지는 애니메이션은 다음을 통해 구현이 된다.

 

calculateCurrentOffsetForPage의 경우에는 pageState의 확장함수로, 입력값으로 주어진 page의 offset값을 계산하여 반환해준다. 지금 화면의 페이지와 대상 페이지의 인덱스 차이에 오프셋 비율을 더해서 계산을 해준다.

 

 

전체 코드

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Weather_animationTheme {
                CustomPager()
            }
        }
    }

    private fun randomSampleImageUrl(
        seed: Int = rangeForRandom.random(),
        width: Int = 300,
        height: Int = width,
    ): String {
        return "https://picsum.photos/seed/$seed/$width/$height"
    }
    @Composable
    fun rememberRandomSampleImageUrl(
        seed: Int = rangeForRandom.random(),
        width: Int = 300,
        height: Int = width,
    ): String = remember { randomSampleImageUrl(seed, width, height) }

    @Preview
    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun CustomPager() {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(Color(0xFFECECEC))
        ) {
            val pagerState = rememberPagerState(pageCount = { 10 })
            HorizontalPager(
                pageSpacing = 16.dp,
                beyondBoundsPageCount = 2,
                state = pagerState,
                modifier = Modifier.fillMaxSize()
            ) { page ->
                Box(modifier = Modifier.fillMaxSize()) {
                    WeatherInformationCard(
                        modifier = Modifier
                            .padding(32.dp)
                            .align(Alignment.Center),
                        pagerState = pagerState,
                        page = page
                    )
                }
            }
        }
    }

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun WeatherInformationCard(
        pagerState: PagerState,
        page: Int,
        modifier: Modifier = Modifier
    ) {
        Card(
            modifier = modifier
        ) {
            Column(modifier = Modifier) {
                val pageOffset = pagerState.calculateCurrentOffsetForPage(page)
                Image(
                    painter = rememberAsyncImagePainter(model = rememberRandomSampleImageUrl(width = 1200)),
                    contentDescription = null,
                    modifier = Modifier
                        .graphicsLayer {
                            val scale = lerp(1f, 1.75f, pageOffset)
                            scaleX = scale
                            scaleY = scale
                        },
                )
            }
        }
    }

    private val rangeForRandom = (0..100000)

    private fun lerp(start: Float, stop: Float, fraction: Float): Float {
        return (1 - fraction) * start + fraction * stop
    }

    @OptIn(ExperimentalFoundationApi::class)
    fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
        return (currentPage - page) + currentPageOffsetFraction
    }
}