From a3ac90643f514c47881d2ae5600bf1d94a2cf892 Mon Sep 17 00:00:00 2001 From: LEE YOU BIN <128459613+leeeyubin@users.noreply.github.com> Date: Tue, 20 Aug 2024 18:34:37 +0900 Subject: [PATCH] =?UTF-8?q?Tab=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : add Tab file * feat : FixedTab 구현 * feat : indicator 색상 지정 * feat : ScrollableTab 구현 * feat : 주석 수정 * feat : indicator 너비 수정 * feat : 주석 수정 * feat : 변수 수정 * feat : 코드 수정 * feat : text 글자수 조건 추가 * feat : 프리뷰 수정 * feat : 프리뷰 수정 * feat : 예외처리 삭제 * feat : 주석 수정 * feat : tab 조건 수정 * feat : tab 조건 수정 * feat : tab 조건 수정 * feat : indicator radius * feat : scrollableTab 중앙 정렬 * feat : 주석 추가 * feat : text 수정 * chore : contentColor 삭제 * chore : add Modifier in Tab * chore : FixedTab, ScrollableTab 기본값 및 프리뷰 추가 * chore : delete object * chore : add indicator animation * chore : delete Tab * chore : import TabBarDefaults --- .../com/yourssu/handy/demo/TabPreview.kt | 61 +++ .../kotlin/com/yourssu/handy/compose/Tab.kt | 460 ++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 app/src/main/kotlin/com/yourssu/handy/demo/TabPreview.kt create mode 100644 compose/src/main/kotlin/com/yourssu/handy/compose/Tab.kt diff --git a/app/src/main/kotlin/com/yourssu/handy/demo/TabPreview.kt b/app/src/main/kotlin/com/yourssu/handy/demo/TabPreview.kt new file mode 100644 index 0000000..37e0206 --- /dev/null +++ b/app/src/main/kotlin/com/yourssu/handy/demo/TabPreview.kt @@ -0,0 +1,61 @@ +package com.yourssu.handy.demo + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import com.yourssu.handy.compose.FixedTab +import com.yourssu.handy.compose.HandyTheme +import com.yourssu.handy.compose.ScrollableTab +import com.yourssu.handy.compose.TabItem + +@Preview(showBackground = true) +@Composable +private fun FixedTabPreview() { + var tabIndex by remember { mutableIntStateOf(0) } + val fixedTabs = listOf("Tab1", "Tab2") + HandyTheme { + Column { + FixedTab( + selectedTabIndex = tabIndex, + ) { + fixedTabs.forEachIndexed { index, title -> + TabItem( + text = title, + selected = index == tabIndex, + onClick = { + tabIndex = index + }, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ScrollableTabPreview() { + var tabIndex by remember { mutableIntStateOf(0) } + val scrollableTabs = listOf("안드로이드", "Tab2", "Tab3", "Tab4", "Tab5", "Tab6") + HandyTheme { + Column { + ScrollableTab( + selectedTabIndex = tabIndex + ) { + scrollableTabs.forEachIndexed { index, title -> + TabItem( + text = title, + selected = index == tabIndex, + onClick = { + tabIndex = index + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/Tab.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/Tab.kt new file mode 100644 index 0000000..b52e840 --- /dev/null +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/Tab.kt @@ -0,0 +1,460 @@ +package com.yourssu.handy.compose + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import com.yourssu.handy.compose.TabBarDefaults.fixedTabIndicatorPadding +import com.yourssu.handy.compose.TabBarDefaults.scrollableTabIndicatorPadding +import com.yourssu.handy.compose.TabBarDefaults.tabHeight +import com.yourssu.handy.compose.TabBarDefaults.tabHorizontalPadding +import com.yourssu.handy.compose.foundation.HandyTypography +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun TabItem( + selected: Boolean, + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + selectedContentColor: Color = HandyTheme.colors.textBasicPrimary, + unselectedContentColor: Color = HandyTheme.colors.textBasicTertiary, +) { + val color = if (selected) selectedContentColor else unselectedContentColor + + Surface( + selected = selected, + onClick = onClick, + modifier = modifier + .padding(horizontal = tabHorizontalPadding) + .height(tabHeight), + enabled = true, + ) { + Box( + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = color, + style = HandyTypography.B1Sb16 + ) + } + } +} + +/** + * [FixedTab]에는 [TabItem] 행이 포함되어 있으며 현재 선택된 탭 아래에 인디케이터가 표시됩니다. + * [FixedTab]은 전체 행을 따라 균등한 간격으로 탭을 배치하며 각 탭은 동일한 공간을 차지합니다. + * + * 텍스트의 글자수는 공백포함 6자를 넘기지 않아야 합니다. + * + * 스크롤되지 않습니다. 각 탭의 너비는 전체 너비의 1/n입니다. + * 최소 2개, 최대 3개 탭을 사용해주세요. + * + * @param selectedTabIndex 현재 선택된 탭의 인덱스 + * @param backgroundColor Tab의 배경색 + * @param contentColor Tab의 콘텐츠 색상 + * @param tabs 이 Tab 내부의 탭 + */ +@Composable +fun FixedTab( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + backgroundColor: Color = HandyTheme.colors.bgBasicDefault, + contentColor: Color = HandyTheme.colors.textBasicPrimary, + tabs: @Composable () -> Unit +) { + Surface( + modifier = modifier.selectableGroup(), + backgroundColor = backgroundColor, + ) { + SubcomposeLayout( + modifier = modifier.fillMaxWidth(), + measurePolicy = { constraints -> + val tabBarWidth = constraints.maxWidth + val measurableTabs = subcompose( + slotId = TabSlots.Tabs, + content = tabs + ) + val tabCount = measurableTabs.size + + val tabWidth = (tabBarWidth / tabCount) + val placeableTabs = measurableTabs.map { + it.measure( + constraints.copy( + minWidth = tabWidth, + maxWidth = tabWidth + ) + ) + } + val tabBarHeight = placeableTabs.first().height + + val tabPositions = List(tabCount) { index -> + TabPosition( + left = (tabWidth * index).toDp(), + width = tabWidth.toDp() + ) + } + + layout(tabBarWidth, tabBarHeight) { + placeableTabs.forEachIndexed { index, placeable -> + placeable.placeRelative( + x = index * tabWidth, + y = 0 + ) + } + + subcompose( + slotId = TabSlots.Divider, + content = { + Divider() + } + ).forEach { + val placeableDivider = it.measure( + constraints.copy( + minWidth = tabBarWidth, + maxWidth = tabBarWidth + ) + ) + placeableDivider.placeRelative( + x = 0, + y = tabBarHeight - placeableDivider.height + ) + } + + subcompose( + slotId = TabSlots.Indicator, + content = { + Indicator( + color = contentColor, + modifier = Modifier.tabIndicatorOffset( + currentTabPosition = tabPositions[selectedTabIndex], + tabMargin = fixedTabIndicatorPadding + ) + ) + } + ).forEach { + val placeableIndicator = it.measure( + Constraints.fixed(tabBarWidth, tabBarHeight) + ) + placeableIndicator.placeRelative( + x = 0, + y = 0 + ) + } + } + } + ) + } +} + +/** + * [ScrollableTab]에는 [TabItem] 행이 포함되어 있으며 현재 탭 아래에 인디케이터가 표시됩니다. + * + * 스크롤됩니다. + * 최소 4개, 최대 탭 수에는 제한이 없습니다. + * 첫 번째 탭 왼쪽 및 마지막 탭의 오른쪽에 16의 여백이 있습니다. + * + * 텍스트의 글자수는 공백포함 6자를 넘기지 않아야 합니다. + * + * @param selectedTabIndex 현재 선택된 탭의 인덱스 + * @param backgroundColor ScrollableTab의 배경색 + * @param contentColor 이 ScrollableTab이 제공하는 기본 콘텐츠 색상 + * @param tabs 이 ScrollableTab 내부의 탭들 + */ +@Composable +fun ScrollableTab( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + backgroundColor: Color = HandyTheme.colors.bgBasicDefault, + contentColor: Color = HandyTheme.colors.textBasicPrimary, + tabs: @Composable () -> Unit +) { + Surface( + modifier = modifier, + backgroundColor = backgroundColor, + ) { + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + val scrollableTabData = remember(scrollState, coroutineScope) { + ScrollableTabData( + scrollState = scrollState, + coroutineScope = coroutineScope + ) + } + SubcomposeLayout( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(align = Alignment.Center) + .horizontalScroll(scrollState) + .selectableGroup() + .clipToBounds() + ) { constraints -> + val minTabWidth = TabBarDefaults.scrollableTabWidth.roundToPx() + val edgePadding = TabBarDefaults.scrollableTabPadding.roundToPx() + val tabConstraints = constraints.copy(minWidth = minTabWidth) + + val placeableTabs = subcompose( + slotId = TabSlots.Tabs, + content = { + tabs() + } + ).map { it.measure(tabConstraints) } + + var tabBarWidth = edgePadding * 2 + val tabBarHeight = placeableTabs.first().height + + placeableTabs.forEach { + tabBarWidth += it.width + } + + layout(tabBarWidth, tabBarHeight) { + val tabPositions = mutableListOf() + var left = edgePadding + + placeableTabs.forEach { + it.placeRelative( + x = left, + y = 0 + ) + tabPositions.add( + TabPosition( + left = left.toDp(), + width = it.width.toDp() + ) + ) + left += it.width + } + + subcompose( + slotId = TabSlots.Divider, + content = { + Divider() + } + ).forEach { + val placeableDivider = it.measure( + constraints.copy( + minWidth = tabBarWidth, + maxWidth = tabBarWidth + ) + ) + placeableDivider.placeRelative( + x = 0, + y = tabBarHeight - placeableDivider.height + ) + } + + subcompose( + slotId = TabSlots.Indicator, + content = { + Indicator( + color = contentColor, + modifier = Modifier.tabIndicatorOffset( + currentTabPosition = tabPositions[selectedTabIndex], + tabMargin = scrollableTabIndicatorPadding + ) + ) + } + ).forEach { + val placeableIndicator = it.measure( + Constraints.fixed(tabBarWidth, tabBarHeight) + ) + placeableIndicator.placeRelative( + x = 0, + y = 0 + ) + } + + scrollableTabData.onLaidOut( + density = this@SubcomposeLayout, + edgeOffset = edgePadding, + tabPositions = tabPositions, + selectedTab = selectedTabIndex + ) + } + } + } +} + +private class ScrollableTabData( + private val scrollState: ScrollState, + private val coroutineScope: CoroutineScope +) { + private var selectedTab: Int? = null + + fun onLaidOut( + density: Density, + edgeOffset: Int, + tabPositions: List, + selectedTab: Int + ) { + if (this.selectedTab != selectedTab) { + this.selectedTab = selectedTab + tabPositions.getOrNull(selectedTab)?.let { + val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions) + if (scrollState.value != calculatedOffset) { + coroutineScope.launch { + scrollState.animateScrollTo( + calculatedOffset, + animationSpec = ScrollableTabRowScrollSpec + ) + } + } + } + } + } + + /** + * 탭 내부에서 탭을 수평으로 중앙에 배치하는 데 필요한 오프셋을 반환합니다. + */ + private fun TabPosition.calculateTabOffset( + density: Density, + edgeOffset: Int, + tabPositions: List + ): Int = with(density) { + val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset + val visibleWidth = totalTabRowWidth - scrollState.maxValue + val tabOffset = left.roundToPx() + val scrollerCenter = visibleWidth / 2 + val tabWidth = width.roundToPx() + val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2) + val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0) + return centeredTabOffset.coerceIn(0, availableSpace) + } +} + +private val ScrollableTabRowScrollSpec: AnimationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing +) + +private enum class TabSlots { + Tabs, + Divider, + Indicator +} + +@Immutable +class TabPosition internal constructor(val left: Dp, val width: Dp) { + val right: Dp get() = left + width + + override fun equals(other: Any?): Boolean = when { + this === other -> true + other !is TabPosition -> false + left != other.left -> false + width != other.width -> false + else -> true + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + width.hashCode() + return result + } + + override fun toString(): String = "TabPosition(left=$left, right=$right, width=$width)" + +} + +/** + * 기본 [Divider]로, 인디케이터 아래에 있는 탭의 하단에 수평으로 배치됩니다. + * + * @param thickness 구분선의 두께 + * @param color 구분선의 색상 + */ +@Composable +fun Divider( + thickness: Dp = 1.dp, + color: Color = HandyTheme.colors.lineBasicLight +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(thickness) + .background(color) + ) +} + +/** + * 기본 [Indicator]로, 구분선 위에 있는 탭의 하단에 배치됩니다. + * + * @param height 인디케이터의 높이 + * @param color 인디케이터의 색상 + */ +@Composable +fun Indicator( + modifier: Modifier = Modifier, + height: Dp = 2.dp, + color: Color = HandyTheme.colors.bgBasicBlack +) { + Box( + modifier + .fillMaxWidth() + .height(height) + .background(color = color) + ) +} + +/** + * 인디케이터의 오프셋과 너비를 조정합니다. + * 각 탭의 양옆에 tabMargin만큼의 마진을 두고, 인디케이터가 해당 마진을 고려하여 조정됩니다. + * 인디케이터는 선택된 탭의 위치에 맞춰서 배치되며, 탭의 너비에서 양옆의 마진을 제외한 너비를 가집니다. + * + * @param currentTabPosition 현재 선택된 탭의 [TabPosition] + * @param tabMargin 인디케이터의 양옆에 적용할 마진 + */ +fun Modifier.tabIndicatorOffset( + currentTabPosition: TabPosition, + tabMargin: Dp +): Modifier = composed { + val indicatorWidth by animateDpAsState(targetValue = currentTabPosition.width - (2 * tabMargin)) + val indicatorOffset by animateDpAsState(targetValue = currentTabPosition.left + tabMargin) + + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = indicatorOffset) + .width(indicatorWidth) + .clip(RoundedCornerShape(100.dp)) +} + +object TabBarDefaults { + val tabHeight = 48.dp + val tabHorizontalPadding = 16.dp + + val fixedTabIndicatorPadding = 28.dp + val scrollableTabIndicatorPadding = 18.dp + + val scrollableTabPadding = 16.dp + val scrollableTabWidth = 56.dp +} \ No newline at end of file