1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.launcher3.widget.picker; 17 18 import static com.android.launcher3.anim.AnimatorListeners.forEndCallback; 19 20 import android.animation.Animator; 21 import android.animation.ObjectAnimator; 22 import android.util.FloatProperty; 23 import android.view.MotionEvent; 24 import android.view.View; 25 import android.view.ViewGroup; 26 import android.widget.TextView; 27 28 import androidx.annotation.NonNull; 29 import androidx.annotation.Nullable; 30 import androidx.recyclerview.widget.RecyclerView; 31 32 import com.android.launcher3.R; 33 import com.android.launcher3.widget.picker.WidgetsSpaceViewHolderBinder.EmptySpaceView; 34 import com.android.launcher3.widget.picker.search.WidgetsSearchBar; 35 36 /** 37 * A controller which measures & updates {@link WidgetsFullSheet}'s views padding, margin and 38 * vertical displacement upon scrolling. 39 */ 40 final class SearchAndRecommendationsScrollController implements 41 RecyclerView.OnChildAttachStateChangeListener { 42 43 private static final FloatProperty<SearchAndRecommendationsScrollController> SCROLL_OFFSET = 44 new FloatProperty<SearchAndRecommendationsScrollController>("scrollAnimOffset") { 45 @Override 46 public void setValue(SearchAndRecommendationsScrollController controller, float offset) { 47 controller.mScrollOffset = offset; 48 controller.updateHeaderScroll(); 49 } 50 51 @Override 52 public Float get(SearchAndRecommendationsScrollController controller) { 53 return controller.mScrollOffset; 54 } 55 }; 56 57 private static final MotionEventProxyMethod INTERCEPT_PROXY = ViewGroup::onInterceptTouchEvent; 58 private static final MotionEventProxyMethod TOUCH_PROXY = ViewGroup::onTouchEvent; 59 60 final SearchAndRecommendationsView mContainer; 61 final View mSearchBarContainer; 62 final WidgetsSearchBar mSearchBar; 63 final TextView mHeaderTitle; 64 final WidgetsRecommendationTableLayout mRecommendedWidgetsTable; 65 @Nullable final View mTabBar; 66 67 private WidgetsRecyclerView mCurrentRecyclerView; 68 private EmptySpaceView mCurrentEmptySpaceView; 69 70 private float mLastScroll = 0; 71 private float mScrollOffset = 0; 72 private Animator mOffsetAnimator; 73 74 private boolean mShouldForwardToRecyclerView = false; 75 76 private int mHeaderHeight; 77 SearchAndRecommendationsScrollController( SearchAndRecommendationsView searchAndRecommendationContainer)78 SearchAndRecommendationsScrollController( 79 SearchAndRecommendationsView searchAndRecommendationContainer) { 80 mContainer = searchAndRecommendationContainer; 81 mSearchBarContainer = mContainer.findViewById(R.id.search_bar_container); 82 mSearchBar = mContainer.findViewById(R.id.widgets_search_bar); 83 mHeaderTitle = mContainer.findViewById(R.id.title); 84 mRecommendedWidgetsTable = mContainer.findViewById(R.id.recommended_widget_table); 85 mTabBar = mContainer.findViewById(R.id.tabs); 86 87 mContainer.setSearchAndRecommendationScrollController(this); 88 } 89 setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView)90 public void setCurrentRecyclerView(WidgetsRecyclerView currentRecyclerView) { 91 boolean animateReset = mCurrentRecyclerView != null; 92 if (mCurrentRecyclerView != null) { 93 mCurrentRecyclerView.removeOnChildAttachStateChangeListener(this); 94 } 95 mCurrentRecyclerView = currentRecyclerView; 96 mCurrentRecyclerView.addOnChildAttachStateChangeListener(this); 97 findCurrentEmptyView(); 98 reset(animateReset); 99 } 100 getHeaderHeight()101 public int getHeaderHeight() { 102 return mHeaderHeight; 103 } 104 updateHeaderScroll()105 private void updateHeaderScroll() { 106 mLastScroll = getCurrentScroll(); 107 mHeaderTitle.setTranslationY(mLastScroll); 108 mRecommendedWidgetsTable.setTranslationY(mLastScroll); 109 110 float searchYDisplacement = Math.max(mLastScroll, -mSearchBarContainer.getTop()); 111 mSearchBarContainer.setTranslationY(searchYDisplacement); 112 113 if (mTabBar != null) { 114 float tabsDisplacement = Math.max(mLastScroll, -mTabBar.getTop() 115 + mSearchBarContainer.getHeight()); 116 mTabBar.setTranslationY(tabsDisplacement); 117 } 118 } 119 getCurrentScroll()120 private float getCurrentScroll() { 121 return mScrollOffset + (mCurrentEmptySpaceView == null ? 0 : mCurrentEmptySpaceView.getY()); 122 } 123 124 /** 125 * Updates the scrollable header height 126 * 127 * @return {@code true} if the header height or dependent property changed. 128 */ updateHeaderHeight()129 public boolean updateHeaderHeight() { 130 boolean hasSizeUpdated = false; 131 132 int headerHeight = mContainer.getMeasuredHeight(); 133 if (headerHeight != mHeaderHeight) { 134 mHeaderHeight = headerHeight; 135 hasSizeUpdated = true; 136 } 137 138 if (mCurrentEmptySpaceView != null 139 && mCurrentEmptySpaceView.setFixedHeight(mHeaderHeight)) { 140 hasSizeUpdated = true; 141 } 142 return hasSizeUpdated; 143 } 144 145 /** Resets any previous view translation. */ reset(boolean animate)146 public void reset(boolean animate) { 147 if (mOffsetAnimator != null) { 148 mOffsetAnimator.cancel(); 149 mOffsetAnimator = null; 150 } 151 152 mScrollOffset = 0; 153 if (!animate) { 154 updateHeaderScroll(); 155 } else { 156 float startValue = mLastScroll - getCurrentScroll(); 157 mOffsetAnimator = ObjectAnimator.ofFloat(this, SCROLL_OFFSET, startValue, 0); 158 mOffsetAnimator.addListener(forEndCallback(() -> mOffsetAnimator = null)); 159 mOffsetAnimator.start(); 160 } 161 } 162 163 /** 164 * Returns {@code true} if a touch event should be intercepted by this controller. 165 */ onInterceptTouchEvent(MotionEvent event)166 public boolean onInterceptTouchEvent(MotionEvent event) { 167 return (mShouldForwardToRecyclerView = proxyMotionEvent(event, INTERCEPT_PROXY)); 168 } 169 170 /** 171 * Returns {@code true} if this controller has intercepted and consumed a touch event. 172 */ onTouchEvent(MotionEvent event)173 public boolean onTouchEvent(MotionEvent event) { 174 return mShouldForwardToRecyclerView && proxyMotionEvent(event, TOUCH_PROXY); 175 } 176 proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method)177 private boolean proxyMotionEvent(MotionEvent event, MotionEventProxyMethod method) { 178 float dx = mCurrentRecyclerView.getLeft() - mContainer.getLeft(); 179 float dy = mCurrentRecyclerView.getTop() - mContainer.getTop(); 180 event.offsetLocation(dx, dy); 181 try { 182 return method.proxyEvent(mCurrentRecyclerView, event); 183 } finally { 184 event.offsetLocation(-dx, -dy); 185 } 186 } 187 188 @Override onChildViewAttachedToWindow(@onNull View view)189 public void onChildViewAttachedToWindow(@NonNull View view) { 190 if (view instanceof EmptySpaceView) { 191 findCurrentEmptyView(); 192 } 193 } 194 195 @Override onChildViewDetachedFromWindow(@onNull View view)196 public void onChildViewDetachedFromWindow(@NonNull View view) { 197 if (view == mCurrentEmptySpaceView) { 198 findCurrentEmptyView(); 199 } 200 } 201 findCurrentEmptyView()202 private void findCurrentEmptyView() { 203 if (mCurrentEmptySpaceView != null) { 204 mCurrentEmptySpaceView.setOnYChangeCallback(null); 205 mCurrentEmptySpaceView = null; 206 } 207 int childCount = mCurrentRecyclerView.getChildCount(); 208 for (int i = 0; i < childCount; i++) { 209 View view = mCurrentRecyclerView.getChildAt(i); 210 if (view instanceof EmptySpaceView) { 211 mCurrentEmptySpaceView = (EmptySpaceView) view; 212 mCurrentEmptySpaceView.setFixedHeight(getHeaderHeight()); 213 mCurrentEmptySpaceView.setOnYChangeCallback(this::updateHeaderScroll); 214 return; 215 } 216 } 217 } 218 219 private interface MotionEventProxyMethod { 220 proxyEvent(ViewGroup view, MotionEvent event)221 boolean proxyEvent(ViewGroup view, MotionEvent event); 222 } 223 } 224