Keyboard down properly collapses App Bar with recyclerview in CoordinatorLayout.
Bug: 330062053
Test: Included in CL.
Change-Id: I7eac424ed279da7bfb00885df168de3fdaf750e1
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
index 0dedce5..967e398 100644
--- a/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/AndroidManifest.xml
@@ -29,6 +29,12 @@
android:name="androidx.coordinatorlayout.widget.CoordinatorWithNestedScrollViewsActivity"
/>
+ <activity
+ android:exported="true"
+ android:theme="@style/Theme.AppCompat.Light"
+ android:name="androidx.coordinatorlayout.widget.CoordinatorWithRecyclerViewActivity"
+ />
+
<activity android:name="androidx.coordinatorlayout.widget.DynamicCoordinatorLayoutActivity"/>
</application>
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java
new file mode 100644
index 0000000..eed663e
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutWithRecyclerViewKeyEventTest.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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 androidx.coordinatorlayout.widget;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.pressKey;
+import static androidx.test.espresso.action.ViewActions.swipeUp;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+
+import static org.junit.Assert.assertEquals;
+
+import android.graphics.Rect;
+import android.view.KeyEvent;
+import android.view.View;
+
+import androidx.coordinatorlayout.test.R;
+import androidx.coordinatorlayout.testutils.AppBarStateChangedListener;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.ext.junit.rules.ActivityScenarioRule;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.testutils.PollingCheck;
+
+import com.google.android.material.appbar.AppBarLayout;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+@SuppressWarnings({"unchecked", "rawtypes"})
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class CoordinatorLayoutWithRecyclerViewKeyEventTest {
+
+ @Rule
+ public ActivityScenarioRule<CoordinatorWithRecyclerViewActivity> mActivityScenarioRule =
+ new ActivityScenarioRule(CoordinatorWithRecyclerViewActivity.class);
+
+ private AppBarLayout mAppBarLayout;
+ private RecyclerView mRecyclerView;
+ // Used to verify that the RecyclerView's item location is zero when using the UP Key.
+ private LinearLayoutManager mLinearLayoutManager;
+
+ private AppBarStateChangedListener.State mAppBarState =
+ AppBarStateChangedListener.State.UNKNOWN;
+
+ public static Matcher<View> isAtLeastHalfVisible() {
+ return new TypeSafeMatcher<View>() {
+ @Override
+ protected boolean matchesSafely(View view) {
+ Rect rect = new Rect();
+ return view.getGlobalVisibleRect(rect)
+ && rect.width() * rect.height() >= (view.getWidth() * view.getHeight()) / 2;
+ }
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("is at least half visible on screen");
+ }
+ };
+ }
+
+ @Before
+ public void setup() {
+ mActivityScenarioRule.getScenario().onActivity(activity -> {
+ mAppBarLayout = activity.mAppBarLayout;
+ mRecyclerView = activity.mRecyclerView;
+ mLinearLayoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
+
+ mAppBarLayout.addOnOffsetChangedListener(new AppBarStateChangedListener() {
+ @Override
+ public void onStateChanged(AppBarLayout appBarLayout, State state) {
+ mAppBarState = state;
+ }
+ });
+ });
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ /*** Tests ***/
+ @Test
+ @LargeTest
+ public void isCollapsingToolbarExpanded_swipeDownMultipleKeysUp_isExpanded() {
+ onView(withId(R.id.recycler_view)).check(matches(isAtLeastHalfVisible()));
+
+ // Scrolls down content and collapses the CollapsingToolbarLayout in the AppBarLayout.
+ onView(withId(R.id.coordinator)).perform(swipeUp());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // Espresso doesn't properly support swipeUp() with a CoordinatorLayout,
+ // AppBarLayout/CollapsingToolbarLayout, and RecyclerView. From testing, it only
+ // handles waiting until the AppBarLayout/CollapsingToolbarLayout is finished with its
+ // transition, NOT waiting until the RecyclerView is finished with its scrolling.
+ // This PollingCheck waits until the scroll is finished in the RecyclerView.
+ AtomicInteger previousScroll = new AtomicInteger();
+ PollingCheck.waitFor(() -> {
+ AtomicInteger currentScroll = new AtomicInteger();
+
+ mActivityScenarioRule.getScenario().onActivity(activity -> {
+ currentScroll.set(activity.mRecyclerView.getScrollY());
+ });
+
+ boolean isDone = currentScroll.get() == previousScroll.get();
+ previousScroll.set(currentScroll.get());
+
+ return isDone;
+ });
+
+ // Verifies the CollapsingToolbarLayout in the AppBarLayout is collapsed.
+ assertEquals(AppBarStateChangedListener.State.COLLAPSED, mAppBarState);
+ onView(withId(R.id.recycler_view)).check(matches(isCompletelyDisplayed()));
+
+ // First up keystroke gains focus (doesn't move any content).
+ onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // Retrieve top visible item in the RecyclerView.
+ int currentTopVisibleItem = mLinearLayoutManager.findFirstCompletelyVisibleItemPosition();
+
+ // Scroll up to the 0 position in the RecyclerView via UP Keystroke.
+ while (currentTopVisibleItem > 0) {
+ onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ currentTopVisibleItem = mLinearLayoutManager.findFirstCompletelyVisibleItemPosition();
+ }
+
+ // This is a fail-safe in case the DPAD UP isn't making any changes, we break out of the
+ // loop.
+ float previousAppBarLayoutY = 0.0f;
+
+ // Performs a key press until the app bar is either expanded completely or no changes are
+ // made in the app bar between the previous call and the current call (failure case).
+ while (mAppBarState != AppBarStateChangedListener.State.EXPANDED
+ && (mAppBarLayout.getY() != previousAppBarLayoutY)
+ ) {
+ previousAppBarLayoutY = mAppBarLayout.getY();
+
+ // Partially expands the CollapsingToolbarLayout.
+ onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_UP));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ // Checks CollapsingToolbarLayout (in the AppBarLayout) is fully expanded.
+ assertEquals(AppBarStateChangedListener.State.EXPANDED, mAppBarState);
+ }
+
+ @Test
+ @LargeTest
+ public void doesAppBarCollapse_pressKeyboardDownMultipleTimes() {
+ onView(withId(R.id.recycler_view)).check(matches(isAtLeastHalfVisible()));
+
+ // Scrolls down content (key) and collapses the CollapsingToolbarLayout in the AppBarLayout.
+ // Gains focus
+ onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_DOWN));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // This is a fail-safe in case the DPAD UP isn't making any changes, we break out of the
+ // loop.
+ float previousAppBarLayoutY = 0.0f;
+
+ // Performs a key press until the app bar is either completely collapsed or no changes are
+ // made in the app bar between the previous call and the current call (failure case).
+ while (mAppBarState != AppBarStateChangedListener.State.COLLAPSED
+ && (mAppBarLayout.getY() != previousAppBarLayoutY)
+ ) {
+ previousAppBarLayoutY = mAppBarLayout.getY();
+
+ // Partial collapse of the CollapsingToolbarLayout.
+ onView(withId(R.id.recycler_view)).perform(pressKey(KeyEvent.KEYCODE_DPAD_DOWN));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ }
+
+ // Espresso doesn't properly support down with a CoordinatorLayout,
+ // AppBarLayout/CollapsingToolbarLayout, and RecyclerView. From testing, it only
+ // handles waiting until the AppBarLayout/CollapsingToolbarLayout is finished with its
+ // transition, NOT waiting until the RecyclerView is finished with its scrolling.
+ // This PollingCheck waits until the scroll is finished in the RecyclerView.
+ AtomicInteger previousScroll = new AtomicInteger();
+ PollingCheck.waitFor(() -> {
+ AtomicInteger currentScroll = new AtomicInteger();
+
+ mActivityScenarioRule.getScenario().onActivity(activity -> {
+ currentScroll.set(activity.mRecyclerView.getScrollY());
+ });
+
+ boolean isDone = currentScroll.get() == previousScroll.get();
+ previousScroll.set(currentScroll.get());
+
+ return isDone;
+ });
+
+ // Verifies the CollapsingToolbarLayout in the AppBarLayout is collapsed.
+ assertEquals(AppBarStateChangedListener.State.COLLAPSED, mAppBarState);
+ onView(withId(R.id.recycler_view)).check(matches(isCompletelyDisplayed()));
+ }
+}
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java
new file mode 100644
index 0000000..52c3a7a
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorWithRecyclerViewActivity.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * 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 androidx.coordinatorlayout.widget;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.coordinatorlayout.BaseTestActivity;
+import androidx.coordinatorlayout.test.R;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.AppBarLayout;
+import com.google.android.material.appbar.CollapsingToolbarLayout;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CoordinatorWithRecyclerViewActivity extends BaseTestActivity {
+ AppBarLayout mAppBarLayout;
+ RecyclerView mRecyclerView;
+
+ @Override
+ protected int getContentViewLayoutResId() {
+ return R.layout.activity_coordinator_with_recycler_view;
+ }
+
+ @Override
+ protected void onContentViewSet() {
+ mAppBarLayout = findViewById(R.id.app_bar_layout);
+ mRecyclerView = findViewById(R.id.recycler_view);
+
+ CollapsingToolbarLayout collapsingToolbarLayout =
+ findViewById(R.id.collapsing_toolbar_layout);
+
+ collapsingToolbarLayout.setTitle("Collapsing Bar Test");
+
+ mRecyclerView = findViewById(R.id.recycler_view);
+ mRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+
+ List<String> data = new ArrayList<String>();
+ for (int index = 0; index < 14; index++) {
+ data.add(String.valueOf(index));
+ }
+
+ MyAdapter adapter = new MyAdapter(data);
+ mRecyclerView.setAdapter(adapter);
+ }
+
+ public static class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder> {
+ private final List<String> mDataForItems;
+
+ public MyAdapter(@NonNull List<String> items) {
+ this.mDataForItems = items;
+ }
+
+ public static class ViewHolder extends RecyclerView.ViewHolder {
+ @NonNull public TextView textViewHeader;
+ @NonNull public TextView textViewSubHeader;
+
+ public ViewHolder(@NonNull View itemView) {
+ super(itemView);
+ textViewHeader = itemView.findViewById(R.id.textViewHeader);
+ textViewSubHeader = itemView.findViewById(R.id.textViewSubHeader);
+ }
+ }
+
+ @NonNull
+ @Override
+ public MyAdapter.ViewHolder onCreateViewHolder(
+ @NonNull ViewGroup parent,
+ int viewType
+ ) {
+ View itemView = LayoutInflater.from(parent.getContext()).inflate(
+ R.layout.recycler_view_with_collapsing_toolbar_list_item,
+ parent,
+ false
+ );
+ return new ViewHolder(itemView);
+ }
+
+ @Override
+ public void onBindViewHolder(
+ MyAdapter.ViewHolder holder,
+ int position
+ ) {
+ String number = mDataForItems.get(position);
+
+ holder.textViewHeader.setText(number);
+ holder.textViewSubHeader.setText("Sub Header for " + number);
+ }
+
+ @Override
+ public int getItemCount() {
+ return mDataForItems.size();
+ }
+ }
+}
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml
new file mode 100644
index 0000000..91bd00f
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/activity_coordinator_with_recycler_view.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2024 The Android Open Source Project
+
+ 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.
+-->
+<!-- Uses a recyclerview with collapsing app bar AND sets scroll flag to include snap. -->
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/coordinator"
+ android:fitsSystemWindows="true"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <!-- App Bar -->
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/app_bar_layout"
+ android:layout_width="match_parent"
+ android:layout_height="200dp">
+
+ <com.google.android.material.appbar.CollapsingToolbarLayout
+ android:id="@+id/collapsing_toolbar_layout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:expandedTitleMarginStart="48dp"
+ app:expandedTitleMarginEnd="64dp"
+ app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
+
+ <View android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:background="#FF0000"/>
+
+ <androidx.appcompat.widget.Toolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:layout_collapseMode="pin" />
+ </com.google.android.material.appbar.CollapsingToolbarLayout>
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <!-- Content -->
+ <FrameLayout
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:orientation="vertical"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recycler_view"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ </androidx.recyclerview.widget.RecyclerView>
+ </FrameLayout>
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml
new file mode 100644
index 0000000..d44cdf1
--- /dev/null
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/res/layout/recycler_view_with_collapsing_toolbar_list_item.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2024 The Android Open Source Project
+ 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.
+ -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp"
+ android:focusable="true">
+ <TextView
+ android:id="@+id/textViewHeader"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="24sp" />
+ <TextView
+ android:id="@+id/textViewSubHeader"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textSize="14sp" />
+</LinearLayout>
diff --git a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
index 36c53be..53ed475 100644
--- a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
+++ b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
@@ -115,8 +115,8 @@
NestedScrollingParent3 {
static final String TAG = "CoordinatorLayout";
static final String WIDGET_PACKAGE_NAME;
- // For the UP/DOWN keys, we scroll 1/10th of the screen.
- private static final float KEY_SCROLL_FRACTION_AMOUNT = 0.1f;
+ // For the UP/DOWN keys, we scroll 20% of the screen.
+ private static final float KEY_SCROLL_FRACTION_AMOUNT = 0.2f;
static {
final Package pkg = CoordinatorLayout.class.getPackage();
@@ -2087,6 +2087,16 @@
ViewCompat.TYPE_NON_TOUCH
);
+ onNestedPreScroll(
+ focusedView,
+ 0,
+ yScrollDelta,
+ mKeyTriggeredScrollConsumed,
+ ViewCompat.TYPE_NON_TOUCH
+ );
+
+ int yScrollDeltaConsumed = mKeyTriggeredScrollConsumed[1];
+
// Reset consumed values to zero.
mKeyTriggeredScrollConsumed[0] = 0;
mKeyTriggeredScrollConsumed[1] = 0;
@@ -2094,7 +2104,7 @@
onNestedScroll(
focusedView,
0,
- 0,
+ yScrollDeltaConsumed,
0,
yScrollDelta,
ViewCompat.TYPE_NON_TOUCH,