Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental Modifier.Node implementation of Modifier.tabIndicatorOffset #996

Open
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

surajsau
Copy link

@surajsau surajsau commented Aug 28, 2023

Overview

Experimental implementation of migrating Modifier.tabIndicatorOffset to Modifier.Node as an example for implementation for Modifier.Node.

Implementation

The Modifier.composed version of Modifier.tabIndicatorOffset looks like this,

// [1]
fun Modifier.tabIndicatorOffset(
    currentTabPosition: TabPosition
): Modifier = composed(
    // [2]
    inspectorInfo = debugInspectorInfo {
    name = "tabIndicatorOffset"
    value = currentTabPosition
    }
) {
    // [3]
    val currentTabWidth by animateDpAsState(targetValue = currentTabPosition.width, ..)
    val indicatorOffset by animateDpAsState(targetValue = currentTabPosition.left, ..)

    // [4]
    fillMaxWidth()
        .wrapContentSize(Alignment.BottomStart)
        // [5]
        .offset(x = indicatorOffset)
        .width(currentTabWidth)
}
  1. created Modifier.Node and the corresponding ModifierNodeElement with currentTabPosition as the constructor parameters.
    fun Modifier.tabIndicatorOffset(currentTabPosition: TabPosition)
        = this.then(TabIndicatorOffsetElement(currentTabPosition))
    
    class TabIndicatorOffsetElement(private val currentTabPosition: TabPosition)
        : ModifierNodeElement<TabIndictorOffsetNode> { /* .. */ }
    
    class TabIndicatorOffsetNode(currentTabPosition: TabPosition): Modifier.Node() { /* .. */ }
  2. the fun InspectorInfo.inspectableProperties() is overridden to the same as that of Modifier.composed version.
    class TabIndicatorOffsetElement(private val currentTabPosition: TabPosition)
        : ModifierNodeElement<TabIndictorOffsetNode> {
    
        override fun InspectorInfo.inspectableProperties() {
            debugInspectorInfo {
                name = "tabIndicatorOffset"
                value = this.currentTabPosition
            }
        }
    }
  3. Modifier.tabIndiciatorOffset has two animateDpAsState states of currentTabWidth and indicatorOffset. If we look at the implementation of animateDpAsState, it basically has a Animatable instance which is animated to a newly set targetValue.
    @Composable
    fun animateDpAsState(targetValue: Dp, ..): State<Dp> {
        return animateValueAsState(targetValue, Dp.VectorConverter, ..)
    }
    
    @Composable
    fun <T, V : AnimationVector> animateValueAsState(targetValue: T, ..): State<T> {
        val animatable = remember { Animatable(targetValue, ..) }
        ..
        LaunchedEffect {
            launch {
                if (newTarget != animatable.targetValue) {
                    animatable.animateTo(newTarget, animSpec)
                }
            }
        }
    }
    So, we maintain Animatable instances for currentTabWidth and indicatorOffset in the Modifier.Node and fire animateTo function when the target value is changed.
    private class TabIndicatorOffsetNode(currentTabPosition: TabPosition): Modifier.Node() {
        private var tabWidthAnimatable: Animatable<Dp, AnimationVector1D>? = null
        var targetTabWidth by mutableStateOf(currentTabPosition.width)
    
        override fun MeasureScope.measure(..) {
            val tabWidthAnim = this.tabWidthAnimatable?.also {
                if (this.targetTabWidth != it.targetValue) {
                    coroutineScope.launch { it.animateTo(targetTabWidth) }
                }
            } ?: Animatable(targetTabWidth, ..).also {
                tabWidthAnimatable = it
            }
        }
    }
    • The target value is maintained as a mutableState object in the Modifier.Node so that we can automatically trigger a Recomposition upon a new value gets set.
    • Since, Modifier.Node's shouldAutoInvalidate = true flag is by default, LayoutModifierNode's invalidation method, measure() is automatically called upon every Recomposition.
      class TabIndicatorOffsetElement(val currentTabPosition: CurrentTabPosition) {
          override fun update(node: TabIndicatorOffsetNode) {
              // setting value to mutableState triggers recomposition
              node.targetTabWidth = currentTabPosition.width
          }
      }
  4. we also have two 'stateless' Modifiers. We can simply add them as it is in the Modifier chain in the same order they're declared.
    fun Modifier.tabIndicatorOffset(..) = this
        .fillMaxWidth()
        .wrapContentSize(Alignment.BottomStart)
        .then(TabIndicatorOffsetElement(..))
  5. The two stateful Modifiers, Modifier.offset and Modifier.width are subsequently implemented as it is from the AOSP code implementation itself. The current values of the Animatable are accessed during implementation of the measure().
    // both .offset and .width implement LayoutModifiereNode
    class TabIndicatorOffsetNode: LayoutModifierNode, .. {
        override fun MeasureScope.measure() {
            val targetWidth = this.tabWidthAnimatable.value.roundToPx()
    
            // Modifier.width implementation
            val wrappedConstraints = constraints.constrain(
                Constraint(minWidth = targetWidth, maxWidth = targetWidth)
            )
            val placeable = measurable.measure(wrappedConstraints)
            layout(placeable.width, placeable.height) {
                val targetOffset = this.indicatorOffsetAnimatable.value.roundToPx()
    
                // Modifier.offset implementation
                placeable.placeRelative(x = targetOffset, y = 0)
            }
        }
    }

Movie

Screen_recording_20230829_000040.mp4

@surajsau surajsau requested a review from a team as a code owner August 28, 2023 15:04
@github-actions
Copy link

Hi @surajsau! Codes seem to be unformatted. To resolve this issue, please run ./gradlew detekt --auto-correct and fix the results of ./gradlew lintDebug.. Thank you for your contribution.

@github-actions github-actions bot temporarily deployed to deploygate-distribution August 28, 2023 15:25 Inactive
@github-actions
Copy link

github-actions bot commented Aug 28, 2023

Test Results

211 tests   211 ✔️  8m 26s ⏱️
  11 suites      0 💤
  11 files        0

Results for commit f923daa.

♻️ This comment has been updated with latest results.

@github-actions github-actions bot temporarily deployed to deploygate-distribution August 28, 2023 15:53 Inactive
@github-actions github-actions bot temporarily deployed to deploygate-distribution August 28, 2023 23:11 Inactive
@takahirom
Copy link
Member

Sorry for the delay! Your implementation is really cool. It's gonna take me a bit to get my head around the changes. I've got a few PRs to go through, so I'll get to yours after that. If you're in a rush for some reason, just let me know. 🙏

@github-actions github-actions bot temporarily deployed to deploygate-distribution August 30, 2023 02:16 Inactive
@surajsau
Copy link
Author

@takahirom Not in any kind of rush. 👍

This PR is more of a reference (also for my DroidKaigi session on Modifier.Node 😅) of how existing Modifier.composed can be migrated to the Modifier.Node APIs.

@DroidKaigi DroidKaigi deleted a comment from github-actions bot Aug 30, 2023
@github-actions github-actions bot temporarily deployed to deploygate-distribution September 2, 2023 08:16 Inactive
@github-actions github-actions bot temporarily deployed to deploygate-distribution September 7, 2023 15:52 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants