[Android Compose] Material 3 버전의 당겨서 새로고침(Pull to Refresh) 화면 구현하기
이 포스팅에선 material 3 기준으로 당겨서 새로고침 화면을 구현한 예시를 확인한다.
이 글의 바탕이 된 코드는 아래 미디엄 링크를 참고했다. 그러나 글 내용 중 rememberPullRefreshState는 material 3에서 사용할 수 없는 API기 때문에 다른 걸 사용하도록 조금 수정했다.
https://medium.com/@anandgaur22/jetpack-compose-pull-to-refresh-fafb4d1a5ea6
당연히 예시기 때문에 실제로 사용하려면 반드시 리팩토링한 후 사용한다.
아래는 구현 완료 후 에뮬레이터에서 테스트한 GIF다.
이제 코드를 확인해보자.
뷰모델을 같이 사용하기 때문에 먼저 libs.versions.toml에 아래 의존성을 추가해준다.
[versions]
lifecycle = "2.8.3"
[libraries]
android-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
먼저 data class를 만든다.
data class Order(
val title: String,
val price: Double,
)
그리고 뷰모델을 구현한다. 특별한 건 없다.
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
class OrderViewModel: ViewModel() {
private val _state = mutableStateOf(OrderScreenState())
val state: State<OrderScreenState> = _state
init {
loadOrders()
}
fun loadOrders() = viewModelScope.launch {
_state.value = _state.value.copy(
isLoading = true
)
delay(1_000L)
_state.value = _state.value.copy(
isLoading = false,
orders = _state.value.orders.toMutableList().also { orderList ->
orderList.add(
index = 0,
element = Order(
title = "주문번호 #${orderList.size + 1}",
price = Random.nextDouble(
from = 10.0,
until = 100.0
)
)
)
}
)
}
}
data class OrderScreenState(
val isLoading: Boolean = false,
val orders: List<Order> = emptyList(),
)
다음으로 액티비티에 표시할 컴포저블 함수를 구현한다.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrderScreen(
isRefreshing: Boolean,
lazyListState: LazyListState = rememberLazyListState(),
onRefresh: () -> Unit
) {
val viewModel: OrderViewModel = viewModel<OrderViewModel>()
val state: OrderScreenState by viewModel.state
val pullRefreshState = rememberPullToRefreshState()
Scaffold(
topBar = { TopAppBar(title = { Text("Items") }) }
) { padding ->
Box(
modifier = Modifier
.nestedScroll(pullRefreshState.nestedScrollConnection)
.fillMaxSize()
.padding(padding)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp)
) {
items(state.orders) {
ListItem(
headlineContent = { Text(text = "${it.title} ($ ${"%.2f".format(it.price)})" ) },
)
}
}
if (pullRefreshState.isRefreshing) {
LaunchedEffect(true) {
onRefresh()
viewModel.loadOrders()
}
}
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
pullRefreshState.startRefresh()
} else {
pullRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
컴포저블 함수까지 만들어졌다면 이제 액티비티에서 호출하면 된다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.example.composetest.ui.theme.ComposeTestTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeTestTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
OrderScreen(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
delay(2_000L)
isRefreshing = false
}
}
)
}
}
}
}
}
1개씩 추가되는 게 감질나면 생성되는 개수를 바꿔서 확인하면 될 것이다.
LazyColumn을 썼기 때문에 생성된 아이템들이 화면을 채운 상태에서 계속 생성하면 스크롤할 수 있다.
그리고 메인 액티비티, 뷰모델에서 각각 delay()를 사용하는데 이는 서버 통신으로 데이터를 받아오는 상황을 만들기 위해 사용했다. 각각 주석 처리하거나 시간을 조정하면 어떻게 되는지 확인해보는 것도 좋을 것이다.
OrderScreen에서 사용한 nestedScrollConnection이 뭔지 궁금하다면 아래 스택오버플로우를 참고한다.
NestedScrollConnection, NestedScrollDispatcher는 중첩 스크롤 시스템에서 다양한 사용 사례를 위해 만들어졌다. 어떤 것이 다른 것보다 유리한 건 없으므로 사용 사례에 맞는 걸 선택해야 한다
- NestedScrollConnection : 스크롤 가능한 자식의 부모가 자식의 스크롤 이벤트에 반응해야 하는 경우 사용한다(스크롤 가능한 아이템이 있는 LazyList 또는 LazyList와 접을 수 있는 헤더가 있는 레이아웃). 자식보다 먼저 스크롤, 플링을 사용하려는 경우 자식으로부터 알림을 받게 되며 자식이 완료한 후 남은 스크롤, 플링을 받는다
- NestedScrollDispatcher : 스크롤 가능한 컴포저블인 경우 수신된 스크롤 이벤트를 부모에게 전달해서 먼저 스크롤 / 플링의 일부를 소비할지 물어본 다음, 직접 스크롤 / 플링하고 완료 후 남은 스크롤 / 플링을 부모에게 보고한다(이미 끝까지 스크롤된 목록인 경우)
중첩 스크롤 계층 구조에 참여하려면 둘을 모두 취하는 nestedScroll modifier를 사용한다. 그러나 자체적으로 스크롤할 수 없고 자식 스크롤에만 반응하려는 부모인 경우에만 감시(watch) / 소비할 수 있다. 그래서 dispatcher 매개변수는 선택사항이다
대부분의 경우 LazyList, Pager, Modifier.scrollable 등이 이미 nestedScroll modifier를 구현하므로 걱정할 필요 없다. 하지만 동작을 커스텀하려면 예를 들어 Pager에 자체 NestedScrollConnection을 전달할 수 있다. 또한 하위 수준 API를 써서 스크롤 가능한 커스텀 컴포넌트를 빌드하는 경우 nestedScroll modifier를 써야 한다
다음은 위 스택오버플로우 답변자가 말한 링크들이다. 먼저 nestedScroll modifier의 사용 예시가 포함된 디벨로퍼 링크다.
그리고 NestedScrollConnection의 사용 예시를 설명하는 미디엄 링크다.
스와이프할 수 있는 카드 형태의 컴포저블을 만드는 코드들을 설명한다.