はじめに
こんにちは!メドピアにてモバイルアプリエンジニアをしている佐藤です。
今回の「ClinPeerアプリ開発の裏側連載記事」では、ScrollableTabRowでのスクロール状態の監視方法について解説していきたいと思います。
tech.medpeer.co.jp
背景
ClinPeerアプリでは、ユーザーが関心のあるカテゴリーをタブとして動的に追加できるようにしており、追加したカテゴリーを上部のタブで表示しています。その際、右側にまだ表示しきれていないタブがあることをユーザーに伝えるため、右端にスクロール可能な場合はアイコンを表示しています。


右:スクロールできない場合はアイコンを非表示
XMLレイアウトのTabLayout + ViewPager2であればaddOnScrollChangedListenerで監視出来るので、Jetpack Composeでも rememberScrollState などを使えば簡単に実装できると思っていましたが、実際には少しハマりどころがありました。
そのため、今回はその解決方法を共有します。
結論
PrimaryScrollableTabRow を使うことで解決できます!
「androidx.compose.material3:material3-*:1.2.0-alpha09」のリリースでスクロール状態が公開されるようになりましたので、本バージョン以降のPrimaryScrollableTabRowを用いれば簡単に監視が出来るようになります。
将来的に仕様が変更される可能性もあるため、バージョンアップ時の挙動に注意しましょう。
PrimaryScrollableTabRowについて
ScrollableTabRowとPrimaryScrollableTabRowの定義を比較してみます。(定義のandroidx.compose.material3:material3のバージョンは1.3.1のものです)
@Composable fun ScrollableTabRow( selectedTabIndex: Int, modifier: Modifier = Modifier, containerColor: Color = TabRowDefaults.primaryContainerColor, contentColor: Color = TabRowDefaults.primaryContentColor, edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding, indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions -> TabRowDefaults.SecondaryIndicator( Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]) ) }, divider: @Composable () -> Unit = @Composable { HorizontalDivider() }, tabs: @Composable () -> Unit )
@ExperimentalMaterial3Api @Composable fun PrimaryScrollableTabRow( selectedTabIndex: Int, modifier: Modifier = Modifier, scrollState: ScrollState = rememberScrollState(), containerColor: Color = TabRowDefaults.primaryContainerColor, contentColor: Color = TabRowDefaults.primaryContentColor, edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding, indicator: @Composable TabIndicatorScope.() -> Unit = @Composable { TabRowDefaults.PrimaryIndicator( Modifier.tabIndicatorOffset(selectedTabIndex, matchContentSize = true), width = Dp.Unspecified, ) }, divider: @Composable () -> Unit = @Composable { HorizontalDivider() }, tabs: @Composable () -> Unit )
ScrollableTabRowではscrollStateを指定することは出来ませんが、PrimaryScrollableTabRowではscrollStateのパラメーターが追加されているので、scrollStateにrememberScrollStateを指定してスクロール状態を監視出来るようにします。
解説
これらの内容を踏まえた上で実装例を書きます。
まず、スクロール状態の監視が行えないScrollableTabRowのコードが下記の通りです。
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"ScrollableTabRowExample",
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
)
},
content = { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
val items = listOf(
"Tab 1",
"Tab 2",
"Tab 3",
"Tab 4",
"Tab 5",
"Tab 6",
"Tab 7",
"Tab 8",
"Tab 9",
"Tab 10"
)
val pagerState =
rememberPagerState(pageCount = { items.size })
val scope = rememberCoroutineScope()
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 0.dp
) {
items.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
modifier = Modifier.height(48.dp)
) {
Text(tab)
}
}
}
HorizontalPager(
state = pagerState
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"page: ${items[it]}"
)
}
}
}
}
)


右側にスクロールが出来る状態である場合にアイコンを表示するといったことをしたい場合、以下の通り変更します。
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"PrimaryScrollableTabRowExample",
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
)
},
content = { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
val items = listOf(
"Tab 1",
"Tab 2",
"Tab 3",
"Tab 4",
"Tab 5",
"Tab 6",
"Tab 7",
"Tab 8",
"Tab 9",
"Tab 10"
)
val pagerState =
rememberPagerState(pageCount = { items.size })
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
// スクロール可能なアイコンの表示状態を管理するフラグを追加する
var showArrow by remember { mutableStateOf(false) }
LaunchedEffect(scrollState.maxValue, scrollState.value) {
// 右側にスクロール可能な状態の場合はshowArrowをtrueにする
showArrow = scrollState.value < scrollState.maxValue
}
Box {
PrimaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 0.dp,
scrollState = scrollState
) {
items.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
modifier = Modifier.height(48.dp)
) {
Text(tab)
}
}
}
if (showArrow) {
// 右側にスクロール可能な状態の場合は右端にアイコンを表示する
Box(
modifier = Modifier
.width(44.dp)
.height(48.dp)
.background(
Brush.linearGradient(
colors = listOf(
Color.White.copy(0f),
Color.White.copy(1f)
)
)
)
.align(Alignment.CenterEnd)
) {
Image(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
HorizontalPager(
state = pagerState
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"page: ${items[it]}"
)
}
}
}
}
)


最後に
PrimaryScrollableTabRowに関する記事がほとんどなかった為、この場を借りて紹介させていただきました。
PrimaryScrollableTabRowを使うことで、スクロール状態を監視してユーザー体験を向上させるような細かなUI調整も可能になります。
本記事が同じような課題で悩んでいる方や今後同じような機能を実装する方の助けになれば幸いです!
是非読者になってください!
メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!
■募集ポジションはこちら medpeer.co.jp
■エンジニア紹介ページはこちら engineer.medpeer.co.jp
■メドピア公式YouTube www.youtube.com
■メドピア公式note
style.medpeer.co.jp