Skip to content

Commit 555c60e

Browse files
authored
Merge pull request #23 from Konyaco/side_nav_indicator_animation
[Navigation] SideNavItem' indicator animation implementation
2 parents fe98378 + c3f0d7d commit 555c60e

File tree

1 file changed

+130
-9
lines changed
  • fluent/src/commonMain/kotlin/com/konyaco/fluent/component

1 file changed

+130
-9
lines changed

fluent/src/commonMain/kotlin/com/konyaco/fluent/component/SideNav.kt

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ import androidx.compose.foundation.verticalScroll
2020
import androidx.compose.runtime.*
2121
import androidx.compose.ui.Alignment
2222
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.composed
2324
import androidx.compose.ui.focus.FocusRequester
2425
import androidx.compose.ui.focus.focusRequester
2526
import androidx.compose.ui.graphics.graphicsLayer
27+
import androidx.compose.ui.layout.*
28+
import androidx.compose.ui.unit.Constraints
29+
import androidx.compose.ui.unit.Dp
2630
import androidx.compose.ui.unit.dp
2731
import com.konyaco.fluent.FluentTheme
2832
import com.konyaco.fluent.LocalTextStyle
@@ -35,9 +39,11 @@ import com.konyaco.fluent.icons.regular.Navigation
3539
import com.konyaco.fluent.icons.regular.Search
3640
import kotlinx.coroutines.delay
3741
import kotlinx.coroutines.launch
42+
import kotlin.math.roundToInt
3843

3944
private val LocalExpand = compositionLocalOf { false }
4045
private val LocalNavigationLevel = compositionLocalOf { 0 }
46+
private val LocalSelectedItemPosition = compositionLocalOf<MutableTransitionState<Float>?> { null }
4147

4248
@Composable
4349
fun SideNav(
@@ -76,9 +82,13 @@ fun SideNav(
7682
}
7783
}
7884
}
85+
val positionState = remember {
86+
MutableTransitionState(0f)
87+
}
7988
CompositionLocalProvider(
8089
LocalExpand provides expanded,
81-
LocalNavigationLevel provides 0
90+
LocalNavigationLevel provides 0,
91+
LocalSelectedItemPosition provides positionState,
8292
) {
8393
autoSuggestionBox?.let {
8494
val focusRequester = remember {
@@ -160,8 +170,20 @@ fun SideNavItem(
160170
hovered -> FluentTheme.colors.subtleFill.secondary
161171
else -> FluentTheme.colors.subtleFill.transparent
162172
}
163-
164-
Column(modifier = modifier) {
173+
var currentPosition by remember {
174+
mutableStateOf(0f)
175+
}
176+
val selectedState = LocalSelectedItemPosition.current
177+
LaunchedEffect(selected, currentPosition) {
178+
if (selected) {
179+
selectedState?.targetState = currentPosition
180+
}
181+
}
182+
Column(
183+
modifier = modifier.onGloballyPositioned {
184+
currentPosition = it.positionInRoot().y
185+
}
186+
) {
165187
Box(Modifier.height(40.dp).fillMaxWidth().padding(4.dp, 2.dp)) {
166188
val navigationLevelPadding = 28.dp * LocalNavigationLevel.current
167189
Layer(
@@ -264,7 +286,7 @@ interface AutoSuggestionBoxScope {
264286

265287
internal class AutoSuggestionBoxScopeImpl(
266288
private val focusRequest: FocusRequester
267-
): AutoSuggestionBoxScope {
289+
) : AutoSuggestionBoxScope {
268290
override fun Modifier.focusHandle() = focusRequester(focusRequest)
269291
}
270292

@@ -288,9 +310,108 @@ fun NavigationItemSeparator(
288310

289311
@Composable
290312
private fun Indicator(modifier: Modifier, display: Boolean) {
291-
val height by updateTransition(display).animateDp(transitionSpec = {
292-
if (targetState) tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing)
293-
else tween(FluentDuration.QuickDuration, easing = FluentEasing.SoftDismissEasing)
294-
}, targetValueByState = { if (it) 16.dp else 0.dp })
295-
Box(modifier.size(3.dp, height).background(FluentTheme.colors.fillAccent.default, CircleShape))
313+
val selectionState = LocalSelectedItemPosition.current
314+
val indicatorState = remember {
315+
MutableTransitionState(display)
316+
}
317+
indicatorState.targetState = display
318+
val animationModifier = if (selectionState != null) {
319+
Modifier.indicatorOffsetAnimation(16.dp, indicatorState, selectionState)
320+
} else {
321+
val height by updateTransition(display).animateDp(transitionSpec = {
322+
if (targetState) tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing)
323+
else tween(FluentDuration.QuickDuration, easing = FluentEasing.SoftDismissEasing)
324+
}, targetValueByState = { if (it) 16.dp else 0.dp })
325+
Modifier.height(height)
326+
}
327+
Box(modifier.width(3.dp).then(animationModifier).background(FluentTheme.colors.fillAccent.default, CircleShape))
328+
}
329+
330+
private fun Modifier.indicatorOffsetAnimation(
331+
size: Dp,
332+
indicatorState: MutableTransitionState<Boolean>,
333+
selectedPosition: MutableTransitionState<Float>,
334+
isVertical: Boolean = true
335+
) = composed {
336+
val fraction by updateTransition(indicatorState).animateFloat(
337+
transitionSpec = {
338+
tween(FluentDuration.VeryLongDuration , easing = FluentEasing.PointToPointEasing)
339+
},
340+
targetValueByState = { if (it) 1f else 0f }
341+
)
342+
//Delay set selected position
343+
if (indicatorState.isIdle && indicatorState.targetState) {
344+
updateTransition(selectedPosition).animateFloat(transitionSpec = {
345+
tween(
346+
FluentDuration.QuickDuration,
347+
easing = FluentEasing.FastInvokeEasing
348+
)
349+
}) { it }
350+
}
351+
layout { measurable, constraints ->
352+
val stickSize = size.toPx()
353+
val containerSize = if (isVertical) {
354+
constraints.maxHeight
355+
} else {
356+
constraints.maxWidth
357+
}
358+
val goBackward = selectedPosition.currentState > selectedPosition.targetState
359+
val contentPadding = ((containerSize - stickSize) / 2).coerceAtLeast(0f)
360+
val extendSize = containerSize - contentPadding
361+
val currentFraction = if (indicatorState.targetState) {
362+
fraction
363+
} else {
364+
1 - fraction
365+
}
366+
val segmentFraction = when {
367+
currentFraction > 0.75 -> (currentFraction - 0.75f) * 4
368+
currentFraction > 0.5 -> (currentFraction - 0.5f) * 4
369+
currentFraction > 0.25 -> (currentFraction - 0.25f) * 4
370+
else -> currentFraction * 4
371+
}
372+
val currentSize = if (!indicatorState.targetState) {
373+
when {
374+
currentFraction <= 0.25 -> androidx.compose.ui.util.lerp(stickSize, extendSize, segmentFraction)
375+
currentFraction <= 0.5f -> androidx.compose.ui.util.lerp(extendSize, 0f, segmentFraction)
376+
else -> 0f
377+
}
378+
} else {
379+
when {
380+
currentFraction > 0.75f -> androidx.compose.ui.util.lerp(
381+
extendSize,
382+
stickSize,
383+
segmentFraction
384+
)
385+
currentFraction > 0.5f -> androidx.compose.ui.util.lerp(0f, extendSize, segmentFraction)
386+
else -> 0f
387+
}
388+
}
389+
val placeable = if (isVertical) {
390+
measurable.measure(Constraints.fixed(constraints.maxWidth, currentSize.roundToInt().coerceAtLeast(0)))
391+
} else {
392+
measurable.measure(Constraints.fixed(currentSize.roundToInt().coerceAtLeast(0), constraints.maxHeight))
393+
}
394+
395+
layout(
396+
width = if (isVertical) placeable.width else constraints.maxWidth,
397+
height = if (isVertical) constraints.maxHeight else placeable.height
398+
) {
399+
val offset = when {
400+
goBackward && !indicatorState.targetState && currentFraction <= 0.25f -> extendSize - currentSize
401+
goBackward && !indicatorState.targetState -> 0f
402+
!goBackward && !indicatorState.targetState && currentFraction <= 0.25f -> contentPadding
403+
!goBackward && !indicatorState.targetState -> containerSize - currentSize
404+
goBackward && currentFraction > 0.75f -> contentPadding
405+
goBackward && currentFraction > 0.5f -> containerSize - currentSize
406+
!goBackward && currentFraction > 0.75f -> extendSize - currentSize
407+
!goBackward && currentFraction > 0.5f -> 0f
408+
else -> 0f
409+
}
410+
if (isVertical) {
411+
placeable.place(0, offset.roundToInt())
412+
} else {
413+
placeable.place(offset.roundToInt(), 0)
414+
}
415+
}
416+
}
296417
}

0 commit comments

Comments
 (0)