Skip to content

Instantly share code, notes, and snippets.

@horseunnamed
Created May 19, 2022 16:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save horseunnamed/76650ce8bccd54271a91a779e6aa452f to your computer and use it in GitHub Desktop.
Save horseunnamed/76650ce8bccd54271a91a779e6aa452f to your computer and use it in GitHub Desktop.
NavBar Impl
/**
* Фиксирует значения для collapsing-трансформации тайтла [NavBar]
* Расчитаны по значениям из дизайн-системы для используемых нами стилей тайтла [NavBar]
*
* @param titleOffsetYPx количество пикселей, на которое надо сдвинуть title при переходе в collapsed-состояние
* @param heightToCollapsePx количество пикселей, на которое должна уменьшиться высота toolbar при collapsing-е
* @param scaleFactor множитель для перехода от размера высоты исходного текста к высоте текста в collapsed-состоянии
*/
@Stable
data class CollapsingTitleSpec(
val titleOffsetYPx: Int,
val heightToCollapsePx: Int,
val scaleFactor: Float,
) {
companion object {
val Section: CollapsingTitleSpec
@Composable
get() = create(fromStyle = HHTextStyles.LargeTitle, toStyle = HHTextStyles.Title1)
val Subsection: CollapsingTitleSpec
@Composable
get() = create(fromStyle = HHTextStyles.Title2, toStyle = HHTextStyles.Title1)
@Composable
private fun create(
fromStyle: TextStyle,
toStyle: TextStyle,
): CollapsingTitleSpec {
with(LocalDensity.current) {
val fromLineHeightPx = fromStyle.lineHeight.roundToPx()
val toLineHeightPx = toStyle.lineHeight.roundToPx()
val collapsedTitlePaddingPx = 16.dp.roundToPx()
val expandedTitlePaddingPx = 8.dp.roundToPx()
return CollapsingTitleSpec(
titleOffsetYPx = collapsedTitlePaddingPx + toLineHeightPx,
heightToCollapsePx = expandedTitlePaddingPx + fromLineHeightPx,
scaleFactor = toLineHeightPx.toFloat() / fromLineHeightPx.toFloat()
)
}
}
}
/**
* Компонент для воспроизведения состояний NavBar из дизайн-системы
*
* @param withBackButton true, если нужно рисовать back button
* @param onBackClick обработчик кликов на back button (по-умолчанию вызывает onBackPressed у активити)
* @param titleContent Slot для отображения тайтла или других компонентов вместо него, пресеты для тайтлов
* можно взять в NavBarTitles.kt
* @param actionsContent Slot для отображения действий в правой верхней части навбара (см NavBarActions.kt)
* @param additionalContent Slot для отображения произвольного контента внизу тулбара (под тайтлом)
* @param expandTitle если false, то слот тайтла будет на одном уровне с backNavigationContent и actionsContent,
* иначе он опускается на одну "строку" ниже (см варианты тайтла "Заголовок раздела/подраздела" в Figma)
* @param scrollConnection объект [NavBarScrollConnection] для реакций NavBar-а на скролл. Для работы реакций
* на скролл необходимо этот же объект передать в общий родительский контейнер для NavBar и скроллящегося контента.
* @param changeElevationOnScroll если true, то NavBar будет менять elevation в зависимости от проскролленности,
* для его работы необходимо так же передать параметр navBarScrollConnection
* @param elevation elevation NavBar-а
*/
@Composable
fun NavBar(
modifier: Modifier = Modifier,
withBackButton: Boolean = true,
onBackClick: (AppCompatActivity?) -> Unit = { it?.onBackPressed() },
titleContent: @Composable () -> Unit = { },
actionsContent: @Composable () -> Unit = { },
additionalContent: (@Composable () -> Unit)? = null,
expandTitle: Boolean = false,
scrollConnection: NavBarScrollConnection? = null,
changeElevationOnScroll: Boolean = false,
elevation: Dp = 4.dp,
) {
val scrollState = scrollConnection?.navBarScrollState
val showElevation = shouldShowNavBarElevation(changeElevationOnScroll, scrollState)
val elevationState = animateDpAsState(if (showElevation) elevation else 0.dp)
Surface(
modifier = Modifier
.then(modifier),
elevation = elevationState.value,
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
NavBarTopRow(
withBackButton = withBackButton,
onBackClick = onBackClick,
actionsContent = actionsContent,
titleContent = titleContent,
expandTitle = expandTitle
)
// Опционально добавляем строку с развернутым тайтлом
if (expandTitle) {
NavBarExpandedTitle(
withBackButton = withBackButton,
titleContent = titleContent,
scrollConnection = scrollConnection
)
}
// Опционально добавляем дополнительный контент в самом низу NavBar
if (additionalContent != null) {
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
additionalContent()
}
}
}
}
}
@Composable
private fun NavBarTopRow(
withBackButton: Boolean = true,
onBackClick: (AppCompatActivity?) -> Unit = { it?.onBackPressed() },
actionsContent: @Composable () -> Unit = { },
titleContent: @Composable () -> Unit = { },
expandTitle: Boolean = false,
) {
Row(
modifier = Modifier
.heightIn(min = 56.dp)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (withBackButton) {
NavBarBackIcon(
onClick = onBackClick
)
Spacers.MSpacer()
}
Box(
modifier = Modifier.weight(1f)
) {
if (!expandTitle) {
titleContent()
} else {
// Для корректного collapsing-а необходимо добиться высоты NavBarTopRow, которая соответствует
// высоте с целевым тайтлом в collapsed-состоянии
NavBarTitle(title = "")
}
}
Spacers.MSpacer()
actionsContent()
}
}
@Composable
private fun NavBarExpandedTitle(
withBackButton: Boolean = true,
titleContent: @Composable () -> Unit = { },
scrollConnection: NavBarScrollConnection? = null,
) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.then(
if (scrollConnection?.collapsingTitleSpec != null) {
Modifier.applyCollapsingTransformation(
scrollState = scrollConnection.navBarScrollState,
collapsingTitleSpec = scrollConnection.collapsingTitleSpec,
backButtonOffsetXDp = if (withBackButton) 40.dp else 0.dp
)
} else {
Modifier
}
)
.padding(bottom = 8.dp)
) {
titleContent()
}
}
private fun shouldShowNavBarElevation(
changeElevationOnScroll: Boolean,
scrollState: NavBarScrollState?,
): Boolean {
return when {
!changeElevationOnScroll -> {
true
}
scrollState != null -> {
scrollState.progress == 1.0f
}
else -> {
false
}
}
}
@SuppressLint("UnnecessaryComposedModifier")
private fun Modifier.applyCollapsingTransformation(
scrollState: NavBarScrollState,
collapsingTitleSpec: CollapsingTitleSpec,
backButtonOffsetXDp: Dp,
) = composed(
inspectorInfo = {
name = "collapsing progress"
value = scrollState.progress
}
) {
layout { measurable, constraints ->
val scrollValuePx = scrollState.value
val placeable = measurable.measure(constraints)
val heightPx = (placeable.height + scrollValuePx).coerceAtLeast(0)
val fraction = scrollState.progress
val scale = lerp(
start = ScaleFactor(scaleX = 1.0f, scaleY = 1.0f),
stop = ScaleFactor(scaleX = collapsingTitleSpec.scaleFactor, scaleY = collapsingTitleSpec.scaleFactor),
fraction = fraction
)
val offsetXDp = lerp(
start = 0.dp,
stop = backButtonOffsetXDp,
fraction = fraction
)
val offsetYPx = scrollValuePx.coerceAtLeast(-collapsingTitleSpec.titleOffsetYPx)
layout(height = heightPx, width = placeable.width) {
placeable.placeWithLayer(x = offsetXDp.roundToPx(), y = offsetYPx) {
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f)
scaleY = scale.scaleY
scaleX = scale.scaleX
}
}
}
}
/**
* Реализует NestedScroll для NavBar
* Для создания инстанса использовать метод [createNavBarScrollConnection]
*/
class NavBarScrollConnection internal constructor(
val navBarScrollState: NavBarScrollState,
val collapsingTitleSpec: CollapsingTitleSpec?,
private val flingBehavior: FlingBehavior,
) : NestedScrollConnection {
private val velocityTracker = RelativeVelocityTracker()
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
velocityTracker.addYDelta(dy)
val toConsume = if (dy < 0) navBarScrollState.dispatchRawDelta(dy) else 0f
return Offset(x = 0f, y = toConsume)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val toConsume = if (dy > 0) navBarScrollState.dispatchRawDelta(dy) else 0f
return Offset(x = 0f, y = toConsume)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val vy = velocityTracker.reset()
val left = if (vy < 0) navBarScrollState.fling(vy, flingBehavior) else vy
return Velocity(x = 0f, y = available.y - left)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val vy = available.y
val left = if (vy > 0) navBarScrollState.fling(vy, flingBehavior) else vy
return Velocity(x = 0f, y = available.y - left)
}
}
/**
* Создает [NavBarScrollConnection], который реализует nested scroll для корректной координации [NavBar]
* со скроллящимся контентом. Для корректной работы необходимо передать результат этого метода в два места:
* 1. Как соответствующий параметр для [NavBar]
* 2. Значение Modifier.nestedScroll для общего родительского контейнера [NavBar] и скроллящегося компонента, с которым
* [NavBar] должен координировать свой nested scroll
*/
@Composable
fun createNavBarScrollConnection(collapsingTitleSpec: CollapsingTitleSpec? = null): NavBarScrollConnection {
val targetScrollValue = collapsingTitleSpec?.let { spec ->
// Целевое количество скролла определяем исходя из того, что больше:
// сдвиг текста вверх или количество высоты, на которое должны уменьшить nav при collapsing-е
min(-spec.heightToCollapsePx, -spec.titleOffsetYPx)
}
val showElevationThreshold = -1.dp.toPx().roundToInt()
val navBarScrollState = rememberSaveable(collapsingTitleSpec, saver = NavBarScrollState.Saver) {
if (collapsingTitleSpec == null || targetScrollValue == null) {
NavBarScrollState(
initialValue = 0,
targetValue = showElevationThreshold,
consumeScroll = false
)
} else {
NavBarScrollState(
initialValue = 0,
targetValue = targetScrollValue,
consumeScroll = true
)
}
}
return NavBarScrollConnection(
navBarScrollState = navBarScrollState,
collapsingTitleSpec = collapsingTitleSpec,
flingBehavior = ScrollableDefaults.flingBehavior()
)
}
@Stable
class NavBarScrollState(
@IntRange(from = Long.MIN_VALUE, to = 0) val initialValue: Int,
@IntRange(from = Long.MIN_VALUE, to = 0) val targetValue: Int,
val consumeScroll: Boolean,
) : ScrollableState {
var value: Int by mutableStateOf(initialValue, structuralEqualityPolicy())
private set
val progress: Float
@FloatRange(from = 0.0, to = 1.0)
get() = if (targetValue == 0) 1f else value.toFloat() / targetValue
/**
* We receive scroll events in floats but represent the scroll position in ints so we have to
* manually accumulate the fractional part of the scroll to not completely ignore it.
*/
private var accumulator: Float = 0f
private val scrollableState = ScrollableState { delta ->
val absolute = value + delta + accumulator
val newValue = absolute.coerceIn(targetValue.toFloat(), 0f)
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.roundToInt()
value += consumedInt
accumulator = consumed - consumedInt
if (consumeScroll) {
// Avoid floating-point rounding error
if (changed) consumed else delta
} else {
0f
}
}
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit,
): Unit = scrollableState.scroll(scrollPriority, block)
override fun dispatchRawDelta(delta: Float): Float =
scrollableState.dispatchRawDelta(delta)
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
suspend fun fling(velocity: Float, flingBehavior: FlingBehavior): Float {
var left = velocity
scrollableState.scroll {
with(flingBehavior) {
left = performFling(velocity)
}
}
return left
}
companion object {
val Saver: Saver<NavBarScrollState, *> = Saver(
save = {
Triple(
it.value,
it.targetValue,
it.consumeScroll
)
},
restore = { NavBarScrollState(it.first, it.second, it.third) }
)
}
}
/**
* HACK: Compose tracks velocity with a local coordinate system which leads to an undesired
* scroll experience. To mitigate this issue, we use RelativeVelocityTracker which tracks velocity
* with a global coordinate system. In NestedScrollConnection, onPreScroll() gives us a delta
* based on a global coordinate, we can use this value to properly calculate the velocity.
*
* The fundamental goal of this class is to override the Compose-calculated scroll velocity to
* our manually calculated one.
*
* @see <a href="https://issuetracker.google.com/issues/179417109">this issue</a>
*/
internal class RelativeVelocityTracker {
private val tracker = VelocityTracker()
private var lastY: Float? = null
fun addYDelta(delta: Float) {
val new = (lastY ?: 0f) + delta
tracker.addPosition(SystemClock.uptimeMillis(), Offset(0f, new))
lastY = new
}
fun reset(): Float {
lastY = null
val velocity = tracker.calculateVelocity()
tracker.resetTracking()
return velocity.y
}
}
// Пример использования NavBar
@Composable
fun NavBarSample() {
val scrollConnection = createNavBarScrollConnection()
Scaffold(
topBar = {
NavBar(
titleContent = {
Text(
text = "Title",
style = HHTextStyles.Title1,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollConnection = scrollConnection,
)
},
modifier = Modifier
.nestedScroll(scrollConnection)
) {
LazyColumn(content = { /* scrollable content */ })
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment