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