diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 4f57265b..0e772dbe 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -208,6 +208,35 @@ public abstract interface class com/rubensousa/dpadrecyclerview/DpadRecyclerView public abstract fun configSmoothScrollByInterpolator (II)Landroid/view/animation/Interpolator; } +public final class com/rubensousa/dpadrecyclerview/DpadScrollableLayout : android/widget/LinearLayout { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun generateDefaultLayoutParams ()Landroid/view/ViewGroup$LayoutParams; + public synthetic fun generateDefaultLayoutParams ()Landroid/widget/LinearLayout$LayoutParams; + public synthetic fun generateLayoutParams (Landroid/util/AttributeSet;)Landroid/view/ViewGroup$LayoutParams; + public synthetic fun generateLayoutParams (Landroid/util/AttributeSet;)Landroid/widget/LinearLayout$LayoutParams; + public fun generateLayoutParams (Landroid/util/AttributeSet;)Lcom/rubensousa/dpadrecyclerview/DpadScrollableLayout$LayoutParams; + public final fun getHeaderHeight ()I + public final fun hideHeader (Z)V + public static synthetic fun hideHeader$default (Lcom/rubensousa/dpadrecyclerview/DpadScrollableLayout;ZILjava/lang/Object;)V + public final fun isHeaderVisible ()Z + public final fun scrollHeaderTo (IZ)V + public static synthetic fun scrollHeaderTo$default (Lcom/rubensousa/dpadrecyclerview/DpadScrollableLayout;IZILjava/lang/Object;)V + public final fun setScrollInterpolator (Landroid/view/animation/Interpolator;)V + public final fun showHeader (Z)V + public static synthetic fun showHeader$default (Lcom/rubensousa/dpadrecyclerview/DpadScrollableLayout;ZILjava/lang/Object;)V +} + +public final class com/rubensousa/dpadrecyclerview/DpadScrollableLayout$LayoutParams : android/widget/LinearLayout$LayoutParams { + public fun (II)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public fun (Landroid/view/ViewGroup$LayoutParams;)V + public fun (Landroid/view/ViewGroup$MarginLayoutParams;)V + public final fun isScrollableView ()Z + public final fun setIsScrollableView (Z)V +} + public final class com/rubensousa/dpadrecyclerview/DpadScroller { public fun ()V public fun (Lcom/rubensousa/dpadrecyclerview/DpadScroller$ScrollDistanceCalculator;)V diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/DpadScrollableLayoutTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/DpadScrollableLayoutTest.kt new file mode 100644 index 00000000..bea998c9 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/DpadScrollableLayoutTest.kt @@ -0,0 +1,444 @@ +/* + * Copyright 2024 Rúben Sousa + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.test.tests.layout + +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.Fragment +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.DpadScrollableLayout +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.R +import com.rubensousa.dpadrecyclerview.test.TestAdapter +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.testing.actions.DpadViewActions +import org.junit.Before +import org.junit.Test +import java.util.concurrent.TimeUnit +import com.rubensousa.dpadrecyclerview.testing.R as testingR + +class DpadScrollableLayoutTest { + + private lateinit var fragmentScenario: FragmentScenario + + @Before + fun setup() { + fragmentScenario = launchFragment() + } + + @Test + fun testLayoutIsOnlyTriggeredOnce() { + var layoutCompleted = 0 + + // when + fragmentScenario.onFragment { fragment -> + layoutCompleted = fragment.layoutsCompleted + } + + // then + assertThat(layoutCompleted).isEqualTo(1) + } + + @Test + fun testInitialLayoutPositions() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + + // when + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + + // then + assertThat(header1Bounds).isEqualTo( + Rect(0, 0, screenWidth, headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, headerHeight, screenWidth, headerHeight * 2) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, headerHeight * 2, screenWidth, screenHeight + headerHeight * 2) + ) + } + + @Test + fun testHidingHeaderWithoutAnimation() { + // given + val headerHeight = getHeaderHeight() + val screenHeight = getHeight() + val screenWidth = getWidth() + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.hideHeader(smooth = false) + } + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + + assertThat(header1Bounds).isEqualTo( + Rect(0, -headerHeight * 2, screenWidth, -headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, -headerHeight, screenWidth, 0) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, 0, screenWidth, screenHeight) + ) + } + + @Test + fun testHidingHeaderWithAnimation() { + // given + val headerHeight = getHeaderHeight() + val screenHeight = getHeight() + val screenWidth = getWidth() + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.hideHeader(smooth = true) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight * 2, bottom = -headerHeight) + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header1Bounds).isEqualTo( + Rect(0, -headerHeight * 2, screenWidth, -headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, -headerHeight, screenWidth, 0) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, 0, screenWidth, screenHeight) + ) + } + + @Test + fun testShowingHeaderWithAnimation() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.hideHeader(smooth = true) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight * 2, bottom = -headerHeight) + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.showHeader(smooth = true) + } + waitViewAtCoordinates(R.id.header1, top = 0, bottom = headerHeight) + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header1Bounds).isEqualTo( + Rect(0, 0, screenWidth, headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, headerHeight, screenWidth, headerHeight * 2) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, headerHeight * 2, screenWidth, screenHeight + headerHeight * 2) + ) + } + + @Test + fun testOffsetAnimation() { + // given + val headerOffset = getHeaderHeight() / 2 + val headerHeight = getHeaderHeight() + val screenHeight = getHeight() + val screenWidth = getWidth() + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.scrollHeaderTo(-headerOffset) + } + + waitViewAtCoordinates( + R.id.header1, + top = -headerOffset, + bottom = -headerOffset + headerHeight + ) + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header1Bounds).isEqualTo( + Rect(0, -headerOffset, screenWidth, -headerOffset + headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, -headerOffset + headerHeight, screenWidth, -headerOffset + headerHeight * 2) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect( + 0, + -headerOffset + headerHeight * 2, + screenWidth, + -headerOffset + headerHeight * 2 + screenHeight + ) + ) + } + + @Test + fun testOffsetIsAdjustedWhenLayoutGetsSmallerWhileHeaderIsNotVisible() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.hideHeader(smooth = true) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight * 2, bottom = -headerHeight) + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.removeViewAt(0) + } + + // then + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header2Bounds).isEqualTo( + Rect(0, -headerHeight, screenWidth, 0) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, 0, screenWidth, screenHeight) + ) + } + + @Test + fun testOffsetIsAdjustedWhenLayoutGetsSmallerWhileHeaderIsVisible() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.scrollHeaderTo(topOffset = -headerHeight / 2) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight / 2, bottom = headerHeight / 2) + + // when + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.removeViewAt(0) + } + + // then + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header2Bounds).isEqualTo( + Rect(0, 0, screenWidth, headerHeight) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, headerHeight, screenWidth, screenHeight + headerHeight) + ) + } + + @Test + fun testOffsetIsAdjustedWhenLayoutGetsBiggerWhenHeaderIsNotVisible() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.hideHeader(smooth = true) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight * 2, bottom = -headerHeight) + + // when + fragmentScenario.onFragment { fragment -> + fragment.header1?.updateLayoutParams { + height *= 2 + } + } + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header1Bounds).isEqualTo( + Rect(0, -headerHeight * 3, screenWidth, -headerHeight) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, -headerHeight, screenWidth, 0) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, 0, screenWidth, screenHeight) + ) + } + + @Test + fun testOffsetIsAdjustedWhenLayoutGetsBiggerWhileHeaderIsVisible() { + // given + val headerHeight = getHeaderHeight() + val screenWidth = getWidth() + val screenHeight = getHeight() + fragmentScenario.onFragment { fragment -> + fragment.scrollableLayout?.scrollHeaderTo(topOffset = -headerHeight / 2) + } + waitViewAtCoordinates(R.id.header1, top = -headerHeight / 2, bottom = headerHeight / 2) + + // when + fragmentScenario.onFragment { fragment -> + fragment.header1?.updateLayoutParams { + height *= 2 + } + } + + // then + val header1Bounds = getViewBounds(R.id.header1) + val header2Bounds = getViewBounds(R.id.header2) + val recyclerViewBounds = getViewBounds(R.id.recyclerView) + assertThat(header1Bounds).isEqualTo( + Rect(0, 0, screenWidth, headerHeight * 2) + ) + assertThat(header2Bounds).isEqualTo( + Rect(0, headerHeight * 2, screenWidth, headerHeight * 3) + ) + assertThat(recyclerViewBounds).isEqualTo( + Rect(0, headerHeight * 3, screenWidth, screenHeight + headerHeight * 3) + ) + } + + private fun waitViewAtCoordinates(viewId: Int, top: Int, bottom: Int) { + Espresso.onView(withId(viewId)) + .perform( + DpadViewActions.waitForCondition("Wait for view at: $top and $bottom", + timeout = 4L, + timeoutUnit = TimeUnit.SECONDS, + condition = { view -> + val intArray = IntArray(2) + view.getLocationInWindow(intArray) + val y = intArray[1] + y == top && y + view.height == bottom + } + ) + ) + } + + private fun getHeight(): Int { + return InstrumentationRegistry.getInstrumentation() + .targetContext.resources.displayMetrics.heightPixels + } + + private fun getWidth(): Int { + return InstrumentationRegistry.getInstrumentation() + .targetContext.resources.displayMetrics.widthPixels + } + + private fun getHeaderHeight(): Int { + return getSizeInPixels(R.dimen.dpadrecyclerview_header_size) + } + + private fun getSizeInPixels(resourceId: Int): Int { + return InstrumentationRegistry.getInstrumentation().targetContext.resources.getDimensionPixelSize( + resourceId + ) + } + + private fun getViewBounds(viewId: Int): Rect { + val rect = Rect() + Espresso.onView(withId(viewId)).perform(DpadViewActions.getViewBounds(rect)) + return rect + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = com.rubensousa.dpadrecyclerview.testing.R.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + + class DpadScrollableFragment : Fragment(R.layout.dpadrecyclerview_scrollable_container) { + + var recyclerView: DpadRecyclerView? = null + var scrollableLayout: DpadScrollableLayout? = null + var header1: View? = null + var header2: View? = null + var layoutsCompleted = 0 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + recyclerView = view.findViewById(R.id.recyclerView) + scrollableLayout = view.findViewById(R.id.scrollableLayout) + header1 = view.findViewById(R.id.header1) + header2 = view.findViewById(R.id.header2) + recyclerView?.apply { + adapter = TestAdapter( + adapterConfiguration = TestAdapterConfiguration( + itemLayoutId = testingR.layout.dpadrecyclerview_test_item_grid, + numberOfItems = 50 + ), + onViewHolderSelected = { + + }, + onViewHolderDeselected = { + + } + ) + setSpanCount(5) + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelSize( + R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + } + recyclerView?.addOnLayoutCompletedListener(object : + DpadRecyclerView.OnLayoutCompletedListener { + override fun onLayoutCompleted(state: RecyclerView.State) { + layoutsCompleted++ + } + }) + } + + override fun onDestroyView() { + super.onDestroyView() + scrollableLayout = null + recyclerView = null + header1 = null + header2 = null + } + + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseHorizontalTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseHorizontalTest.kt index d6df9cbb..b415c9a3 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseHorizontalTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/ReverseHorizontalTest.kt @@ -21,7 +21,10 @@ import com.rubensousa.dpadrecyclerview.ChildAlignment import com.rubensousa.dpadrecyclerview.ParentAlignment import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration -import com.rubensousa.dpadrecyclerview.test.helpers.* +import com.rubensousa.dpadrecyclerview.test.helpers.getRecyclerViewBounds +import com.rubensousa.dpadrecyclerview.test.helpers.getRelativeItemViewBounds +import com.rubensousa.dpadrecyclerview.test.helpers.selectPosition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest import com.rubensousa.dpadrecyclerview.testfixtures.LayoutConfig import com.rubensousa.dpadrecyclerview.testfixtures.RowLayout @@ -103,12 +106,14 @@ class ReverseHorizontalTest : DpadRecyclerViewTest() { repeat(5) { KeyEvents.pressLeft() row.scrollLeft() + waitForIdleScrollState() assertChildrenPositions(row) } repeat(5) { KeyEvents.pressRight() row.scrollRight() + waitForIdleScrollState() assertChildrenPositions(row) } } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/VerticalColumnTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/VerticalColumnTest.kt index 8cf62950..21e0fdd7 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/VerticalColumnTest.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/layout/VerticalColumnTest.kt @@ -145,22 +145,6 @@ class VerticalColumnTest : DpadRecyclerViewTest() { assertChildrenPositions(column) } - @Test - fun testExtraLayoutSpaceIsAddedAtTopDuringScroll() { - repeat(10) { - scrollDown() - } - onRecyclerView("Change extra layout space") { recyclerView -> - recyclerView.setExtraLayoutSpaceStrategy(object : ExtraLayoutSpaceStrategy { - override fun calculateStartExtraLayoutSpace(state: RecyclerView.State): Int { - return column.getSize() - } - }) - } - column.setExtraLayoutSpace(start = column.getSize()) - assertChildrenPositions(column) - } - @Test fun testLayoutListenerIsInvoked() { val childCount = column.getChildCount() diff --git a/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_scrollable_container.xml b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_scrollable_container.xml new file mode 100644 index 00000000..490eddac --- /dev/null +++ b/dpadrecyclerview/src/androidTest/res/layout/dpadrecyclerview_scrollable_container.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/dpadrecyclerview/src/androidTest/res/values/dimens.xml b/dpadrecyclerview/src/androidTest/res/values/dimens.xml index 6e1abf27..616fc50f 100644 --- a/dpadrecyclerview/src/androidTest/res/values/dimens.xml +++ b/dpadrecyclerview/src/androidTest/res/values/dimens.xml @@ -16,4 +16,5 @@ 16dp + 100dp \ No newline at end of file diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScrollableLayout.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScrollableLayout.kt new file mode 100644 index 00000000..a13e552b --- /dev/null +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadScrollableLayout.kt @@ -0,0 +1,294 @@ +/* + * Copyright 2024 Rúben Sousa + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.animation.Interpolator +import android.widget.LinearLayout +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * A layout that behaves similarly to `AppBarLayout` inside a `CoordinatorLayout` + * but with the caveat that nested scrolling is simulated and not actually real. + * + * Use it when you need a fixed header at the top of a [DpadRecyclerView] that can scroll away, + * while supporting recycling at the same time. + * + * Use [scrollHeaderTo] to scroll the layout to a specific top offset. + * + * To scroll the header away, use [hideHeader]. + * + * To show it back again, use [showHeader] + */ +class DpadScrollableLayout @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : LinearLayout(context, attrs) { + + var headerHeight = 0 + private set + + var isHeaderVisible = false + private set + + private var pendingOffset: Int? = null + private var headerHeightChanged = false + private var currentAnimator: ScrollAnimator? = null + + // From RecyclerView + private var scrollInterpolator = Interpolator { t -> + var output = t + output -= 1.0f + output * output * output * output * output + 1.0f + } + + init { + // Only supports vertical layouts + orientation = VERTICAL + } + + override fun generateDefaultLayoutParams(): LayoutParams { + return LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams { + return LayoutParams(context, attrs) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure( + widthMeasureSpec, MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(heightMeasureSpec), + MeasureSpec.EXACTLY + ) + ) + val matchParentHeight = measuredHeight + super.onMeasure( + widthMeasureSpec, MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(heightMeasureSpec), + MeasureSpec.UNSPECIFIED + ) + ) + var childHeight = 0 + var newHeaderHeight = 0 + for (i in 0 until childCount) { + val view = getChildAt(i) + if (view != null) { + val layoutParams = view.layoutParams as LayoutParams + if (layoutParams.isScrollableView) { + childHeight += matchParentHeight + } else { + newHeaderHeight += view.measuredHeight + childHeight += view.measuredHeight + } + } + } + setMeasuredDimension(measuredWidth, childHeight) + if (newHeaderHeight != headerHeight) { + headerHeight = newHeaderHeight + headerHeightChanged = true + } + } + + override fun measureChildWithMargins( + child: View?, + parentWidthMeasureSpec: Int, + widthUsed: Int, + parentHeightMeasureSpec: Int, + heightUsed: Int + ) { + val lp = child!!.layoutParams as LayoutParams + val childWidthMeasureSpec = getChildMeasureSpec( + parentWidthMeasureSpec, + paddingLeft + paddingRight + lp.leftMargin + lp.rightMargin + widthUsed, + lp.width + ) + val verticalPadding = + (paddingTop + paddingBottom + lp.topMargin + lp.bottomMargin + heightUsed) + val childHeightMeasureSpec = if (lp.isScrollableView) { + getChildMeasureSpec( + MeasureSpec.EXACTLY, + verticalPadding, + measuredHeight + ) + } else { + getChildMeasureSpec( + parentHeightMeasureSpec, + verticalPadding, + lp.height + ) + } + child.measure(childWidthMeasureSpec, childHeightMeasureSpec) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // Ensure that the new offset is constrained by the new header height + if (headerHeightChanged && oldh != 0) { + // If the header was previously visible, ensure that it's correctly aligned + if (isHeaderVisible) { + val firstChild = getChildAt(0) + if (firstChild != null && firstChild.top < 0) { + // Make sure the first child is aligned to the top + offsetTopAndBottom(-firstChild.top) + } + } else { + // If the header was not previously visible, ensure it stays that way + offsetTopAndBottom(-(headerHeight - top)) + } + } + pendingOffset?.let { + if (height != 0) { + offsetTopAndBottom(it) + pendingOffset = null + } + } + if (oldh == 0 && h != 0) { + isHeaderVisible = true + } + } + + fun setScrollInterpolator(interpolator: Interpolator) { + scrollInterpolator = interpolator + } + + fun showHeader(smooth: Boolean = true) { + scrollHeaderTo(0, smooth) + } + + fun hideHeader(smooth: Boolean = true) { + scrollHeaderTo(-headerHeight, smooth) + } + + fun scrollHeaderTo(topOffset: Int, smooth: Boolean = true) { + val targetOffset = max(topOffset, -headerHeight) + val currentOffset = top + if (currentOffset == targetOffset) { + return + } + if (height == 0 || !isLaidOut) { + pendingOffset = topOffset + return + } + currentAnimator?.cancel() + if (smooth) { + val animator = ScrollAnimator( + scrollInterpolator = scrollInterpolator, + scrollDuration = computeScrollDuration(targetOffset - currentOffset), + onUpdate = { fraction -> + offset(fraction = fraction, initial = currentOffset, target = targetOffset) + } + ) + currentAnimator = animator + animator.start() + } else { + offsetTo(topOffset - top) + } + } + + // From RecyclerView + private fun computeScrollDuration(dy: Int): Long { + val absDy = abs(dy.toDouble()).toInt() + val containerSize = height + val duration = (((absDy / containerSize) + 1) * 300) + return min(duration.toDouble(), 2000.0).toLong() + } + + private fun offset(fraction: Float, initial: Int, target: Int) { + val currentTop = top + val nextOffset = initial + (target - initial) * fraction + val diff = nextOffset - currentTop + offsetTo(diff.toInt()) + } + + private fun offsetTo(offset: Int) { + offsetTopAndBottom(offset) + isHeaderVisible = top > -headerHeight + } + + private class ScrollAnimator( + private val scrollInterpolator: Interpolator, + private val scrollDuration: Long, + private val onUpdate: (fraction: Float) -> Unit + ) : ValueAnimator.AnimatorUpdateListener { + + private val animator = ValueAnimator() + private var canceled = false + + init { + animator.apply { + setDuration(scrollDuration) + interpolator = scrollInterpolator + addUpdateListener(this@ScrollAnimator) + setFloatValues(0f, 1f) + } + } + + override fun onAnimationUpdate(animation: ValueAnimator) { + if (canceled) { + return + } + val value = animation.animatedValue as Float + onUpdate(value) + } + + fun start() { + animator.start() + } + + fun cancel() { + canceled = true + animator.cancel() + } + + } + + class LayoutParams : LinearLayout.LayoutParams { + + var isScrollableView = false + private set + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + val typedArray: TypedArray = context.obtainStyledAttributes( + attrs, R.styleable.DpadScrollableLayout_Layout + ) + isScrollableView = typedArray.getBoolean( + R.styleable.DpadScrollableLayout_Layout_dpadScrollableLayoutScrollableView, + false + ) + typedArray.recycle() + } + + constructor(width: Int, height: Int) : super(width, height) + constructor(source: MarginLayoutParams) : super(source) + constructor(source: ViewGroup.LayoutParams) : super(source) + + fun setIsScrollableView(enable: Boolean) { + isScrollableView = enable + } + } + +} diff --git a/dpadrecyclerview/src/main/res/values/attrs.xml b/dpadrecyclerview/src/main/res/values/attrs.xml index 28a2a95d..1fdefff2 100644 --- a/dpadrecyclerview/src/main/res/values/attrs.xml +++ b/dpadrecyclerview/src/main/res/values/attrs.xml @@ -38,5 +38,9 @@ + + + + diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/layout/ScrollableLayoutFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/layout/ScrollableLayoutFragment.kt new file mode 100644 index 00000000..0b3dbf53 --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/layout/ScrollableLayoutFragment.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Rúben Sousa + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.layout + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.OnViewFocusedListener +import com.rubensousa.dpadrecyclerview.sample.R +import com.rubensousa.dpadrecyclerview.sample.databinding.ScreenScrollableLayoutBinding +import com.rubensousa.dpadrecyclerview.sample.ui.screen.grid.GridItemAdapter +import com.rubensousa.dpadrecyclerview.sample.ui.screen.grid.GridItemViewHolder +import com.rubensousa.dpadrecyclerview.sample.ui.viewBinding +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration + +class ScrollableLayoutFragment : Fragment(R.layout.screen_scrollable_layout) { + + private val binding by viewBinding(ScreenScrollableLayoutBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val itemAdapter = GridItemAdapter(object : GridItemViewHolder.ItemClickListener { + override fun onViewHolderClicked() { + + } + }) + itemAdapter.submitList(List(50) { it }) + val headerLayout = binding.scrollableLayout + val recyclerView = binding.recyclerView + recyclerView.apply { + setSpanCount(5) + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelOffset(R.dimen.grid_item_spacing) + ) + ) + adapter = itemAdapter + } + binding.header1.requestFocus() + binding.header1.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + headerLayout.showHeader() + } + } + binding.header2.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + headerLayout.showHeader() + } + } + recyclerView.addOnViewFocusedListener(object : OnViewFocusedListener { + override fun onViewFocused(parent: RecyclerView.ViewHolder, child: View) { + recyclerView.findContainingViewHolder(child)?.let { viewHolder -> + val row = + viewHolder.absoluteAdapterPosition / binding.recyclerView.getSpanCount() + when (row) { + 0 -> headerLayout.scrollHeaderTo(-headerLayout.headerHeight / 4) + else -> headerLayout.hideHeader() + } + } + } + }) + } + +} diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt index f26d212b..da65fd84 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt @@ -135,6 +135,10 @@ class MainViewModel : ViewModel() { ScreenDestination( direction = MainFragmentDirections.openHorizontalLeanback(), title = "Searching for next view" + ), + ScreenDestination( + direction = MainFragmentDirections.openScrollableLayout(), + title = "Scrollable layout with header" ) ), ) diff --git a/sample/src/main/res/layout/screen_scrollable_layout.xml b/sample/src/main/res/layout/screen_scrollable_layout.xml new file mode 100644 index 00000000..5d692650 --- /dev/null +++ b/sample/src/main/res/layout/screen_scrollable_layout.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/sample/src/main/res/navigation/nav_graph.xml b/sample/src/main/res/navigation/nav_graph.xml index 022b4195..b38fed18 100644 --- a/sample/src/main/res/navigation/nav_graph.xml +++ b/sample/src/main/res/navigation/nav_graph.xml @@ -38,6 +38,10 @@ android:id="@+id/open_horizontal_leanback" app:destination="@id/leanback_horizontal_fragment" /> + + @@ -233,4 +237,9 @@ + + +