material3 のドキュメントでいう『Swipe within the content area』を compose で実現する方法が v1.1.1 の compose.material3 にはなさそうだったため、その実装方法のメモです。
はじめに
こちらの youtube で勉強させてもらった内容のになります。
本当に本当にありがとうございます。
本記事の実装で、多くのアプリで見るような以下のような挙動が実現可能です。
- タブクリックによる画面遷移
- 画面内を左右にスワイプすることによる画面遷移
- (タブと表示内容が連動)
環境
androidx.compose:compose-bom:2023.06.01 androidx.compose.material3:material3:1.1.1
コード
注意点
- SwipableTabRows が material3 にあるけどそれは?
- SwipableTabRows を使うとタブが長い時にその中でスクロールが可能になる
- content area が左右スクロール可能になるわけではない
- HorizontalPager と rememberPagerState を使ってページングを実装
- TabRow とは連携が取れてないので、LaunchedEffect を使って state 同士を同期
package jp.mydns.kokoichi0206.playground import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun SwipableTabRows() { Scaffold { pad -> Box( modifier = Modifier .fillMaxSize() .padding(pad) ) { SwipableTabRowContents() } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun SwipableTabRowContents() { val tabItems = listOf( TabItem( title = "Home", unselectedIcon = Icons.Outlined.Home, selectedIcon = Icons.Filled.Home, ), TabItem( title = "Browse", unselectedIcon = Icons.Outlined.ShoppingCart, selectedIcon = Icons.Filled.ShoppingCart, ), TabItem( title = "Account", unselectedIcon = Icons.Outlined.AccountCircle, selectedIcon = Icons.Filled.AccountCircle, ), ) var selectedTabIndex by remember { mutableIntStateOf(0) } val pagerState = rememberPagerState { tabItems.size } // pagerState を selectedTabIndex の変更に追従させる。 LaunchedEffect(selectedTabIndex) { Log.d("swipable tab rows", "selectedTabIndex: $selectedTabIndex") pagerState.animateScrollToPage(selectedTabIndex) } // selectedTabIndex を pagerState の変更に追従させる。 LaunchedEffect(pagerState.currentPage, pagerState.isScrollInProgress) { Log.d("swipable tab rows", "pagerState.currentPage: ${pagerState.currentPage}") if (!pagerState.isScrollInProgress) { selectedTabIndex = pagerState.currentPage } } // ================== うまくいかない例 ================== // 2 つ以上離れたタブがクリックされた時に、1つずつ動く挙動をしてしまう。 // そのため、1つ動いた時点で selectedTabIndex が変更され、それにより animateScrollToPage がトリガーされて、 // 移動が1つで終わってしまう事象が発生する。 // LaunchedEffect(pagerState.currentPage) { // Log.d("swipable tab rows", "pagerState.currentPage: ${pagerState.currentPage}") // selectedTabIndex = pagerState.currentPage // } Column( modifier = Modifier .fillMaxSize() .background(Color.White) ) { // ScrollableTabRow is not waht i want... TabRow(selectedTabIndex = selectedTabIndex) { tabItems.forEachIndexed { index, item -> Tab( selected = index == selectedTabIndex, onClick = { selectedTabIndex = index }, text = { Text(text = item.title) }, icon = { Icon( imageVector = if (index == selectedTabIndex) { item.selectedIcon } else item.unselectedIcon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) } ) } } HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() .weight(1f) ) { index -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text(text = tabItems[index].title) } } } } data class TabItem( val title: String, val unselectedIcon: ImageVector, val selectedIcon: ImageVector, )