1 package com.android.launcher3.util;
2 
3 import static android.content.Intent.ACTION_WALLPAPER_CHANGED;
4 
5 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
6 
7 import android.app.WallpaperManager;
8 import android.content.Context;
9 import android.os.Handler;
10 import android.os.IBinder;
11 import android.os.Message;
12 import android.os.SystemClock;
13 import android.util.Log;
14 import android.view.animation.Interpolator;
15 
16 import androidx.annotation.AnyThread;
17 
18 import com.android.app.animation.Interpolators;
19 import com.android.launcher3.Utilities;
20 import com.android.launcher3.Workspace;
21 
22 /**
23  * Utility class to handle wallpaper scrolling along with workspace.
24  */
25 public class WallpaperOffsetInterpolator {
26 
27     private static final int[] sTempInt = new int[2];
28     private static final String TAG = "WPOffsetInterpolator";
29     private static final int ANIMATION_DURATION = 250;
30 
31     // Don't use all the wallpaper for parallax until you have at least this many pages
32     private static final int MIN_PARALLAX_PAGE_SPAN = 4;
33 
34     private final SimpleBroadcastReceiver mWallpaperChangeReceiver =
35             new SimpleBroadcastReceiver(i -> onWallpaperChanged());
36     private final Workspace<?> mWorkspace;
37     private final boolean mIsRtl;
38     private final Handler mHandler;
39 
40     private boolean mRegistered = false;
41     private IBinder mWindowToken;
42     private boolean mWallpaperIsLiveWallpaper;
43 
44     private boolean mLockedToDefaultPage;
45     private int mNumScreens;
46 
WallpaperOffsetInterpolator(Workspace<?> workspace)47     public WallpaperOffsetInterpolator(Workspace<?> workspace) {
48         mWorkspace = workspace;
49         mIsRtl = Utilities.isRtl(workspace.getResources());
50         mHandler = new OffsetHandler(workspace.getContext());
51     }
52 
53     /**
54      * Locks the wallpaper offset to the offset in the default state of Launcher.
55      */
setLockToDefaultPage(boolean lockToDefaultPage)56     public void setLockToDefaultPage(boolean lockToDefaultPage) {
57         mLockedToDefaultPage = lockToDefaultPage;
58     }
59 
isLockedToDefaultPage()60     public boolean isLockedToDefaultPage() {
61         return mLockedToDefaultPage;
62     }
63 
64     /**
65      * Computes the wallpaper offset as an int ratio (out[0] / out[1])
66      *
67      * TODO: do different behavior if it's  a live wallpaper?
68      */
wallpaperOffsetForScroll(int scroll, int numScrollableScreens, final int[] out)69     private void wallpaperOffsetForScroll(int scroll, int numScrollableScreens, final int[] out) {
70         out[1] = 1;
71 
72         // To match the default wallpaper behavior in the system, we default to either the left
73         // or right edge on initialization
74         if (mLockedToDefaultPage || numScrollableScreens <= 1) {
75             out[0] =  mIsRtl ? 1 : 0;
76             return;
77         }
78 
79         // Distribute the wallpaper parallax over a minimum of MIN_PARALLAX_PAGE_SPAN workspace
80         // screens, not including the custom screen, and empty screens (if > MIN_PARALLAX_PAGE_SPAN)
81         int numScreensForWallpaperParallax = mWallpaperIsLiveWallpaper ? numScrollableScreens :
82                         Math.max(MIN_PARALLAX_PAGE_SPAN, numScrollableScreens);
83 
84         // Offset by the custom screen
85 
86         // Don't confuse screens & pages in this function. In a phone UI, we often use screens &
87         // pages interchangeably. However, in a n-panels UI, where n > 1, the screen in this class
88         // means the scrollable screen. Each screen can consist of at most n panels.
89         // Each panel has at most 1 page. Take 5 pages in 2 panels UI as an example, the Workspace
90         // looks as follow:
91         //
92         // S: scrollable screen, P: page, <E>: empty
93         //   S0        S1         S2
94         // _______   _______   ________
95         // |P0|P1|   |P2|P3|   |P4|<E>|
96         // ¯¯¯¯¯¯¯   ¯¯¯¯¯¯¯   ¯¯¯¯¯¯¯¯
97         int endIndex = getNumPagesExcludingEmpty() - 1;
98         final int leftPageIndex = mIsRtl ? endIndex : 0;
99         final int rightPageIndex = mIsRtl ? 0 : endIndex;
100 
101         // Calculate the scroll range
102         int leftPageScrollX = mWorkspace.getScrollForPage(leftPageIndex);
103         int rightPageScrollX = mWorkspace.getScrollForPage(rightPageIndex);
104         int scrollRange = rightPageScrollX - leftPageScrollX;
105         if (scrollRange <= 0) {
106             out[0] = 0;
107             return;
108         }
109 
110         // Sometimes the left parameter of the pages is animated during a layout transition;
111         // this parameter offsets it to keep the wallpaper from animating as well
112         int adjustedScroll = scroll - leftPageScrollX -
113                 mWorkspace.getLayoutTransitionOffsetForPage(0);
114         adjustedScroll = Utilities.boundToRange(adjustedScroll, 0, scrollRange);
115         out[1] = (numScreensForWallpaperParallax - 1) * scrollRange;
116 
117         // The offset is now distributed 0..1 between the left and right pages that we care about,
118         // so we just map that between the pages that we are using for parallax
119         int rtlOffset = 0;
120         if (mIsRtl) {
121             // In RTL, the pages are right aligned, so adjust the offset from the end
122             rtlOffset = out[1] - (numScrollableScreens - 1) * scrollRange;
123         }
124         out[0] = rtlOffset + adjustedScroll * (numScrollableScreens - 1);
125     }
126 
wallpaperOffsetForScroll(int scroll)127     public float wallpaperOffsetForScroll(int scroll) {
128         wallpaperOffsetForScroll(scroll, getNumScrollableScreensExcludingEmpty(), sTempInt);
129         return ((float) sTempInt[0]) / sTempInt[1];
130     }
131 
132     /**
133      * Returns the number of screens that can be scrolled.
134      *
135      * <p>In an usual phone UI, the number of scrollable screens is equal to the number of
136      * CellLayouts because each screen has exactly 1 CellLayout.
137      *
138      * <p>In a n-panels UI, a screen shows n panels. Each panel has at most 1 CellLayout. Take
139      * 2-panels UI as an example: let's say there are 5 CellLayouts in the Workspace. the number of
140      * scrollable screens will be 3 = ⌈5 / 2⌉.
141      */
getNumScrollableScreensExcludingEmpty()142     private int getNumScrollableScreensExcludingEmpty() {
143         float numOfPages = getNumPagesExcludingEmpty();
144         return (int) Math.ceil(numOfPages / mWorkspace.getPanelCount());
145     }
146 
147     /**
148      * Returns the number of non-empty pages in the Workspace.
149      *
150      * <p>If a user starts dragging on the rightmost (or leftmost in RTL), an empty CellLayout is
151      * added to the Workspace. This empty CellLayout add as a hover-over target for adding a new
152      * page. To avoid janky motion effect, we ignore this empty CellLayout.
153      */
getNumPagesExcludingEmpty()154     private int getNumPagesExcludingEmpty() {
155         int numOfPages = mWorkspace.getChildCount();
156         if (numOfPages >= MIN_PARALLAX_PAGE_SPAN && mWorkspace.hasExtraEmptyScreens()) {
157             return numOfPages - mWorkspace.getPanelCount();
158         } else {
159             return numOfPages;
160         }
161     }
162 
syncWithScroll()163     public void syncWithScroll() {
164         int numScreens = getNumScrollableScreensExcludingEmpty();
165         wallpaperOffsetForScroll(mWorkspace.getScrollX(), numScreens, sTempInt);
166         Message msg = Message.obtain(mHandler, MSG_UPDATE_OFFSET, sTempInt[0], sTempInt[1],
167                 mWindowToken);
168         if (numScreens != mNumScreens) {
169             if (mNumScreens > 0) {
170                 // Don't animate if we're going from 0 screens
171                 msg.what = MSG_START_ANIMATION;
172             }
173             mNumScreens = numScreens;
174             updateOffset();
175         }
176         msg.sendToTarget();
177     }
178 
179     /** Returns the number of pages used for the wallpaper parallax. */
getNumPagesForWallpaperParallax()180     public int getNumPagesForWallpaperParallax() {
181         if (mWallpaperIsLiveWallpaper) {
182             return mNumScreens;
183         } else {
184             return Math.max(MIN_PARALLAX_PAGE_SPAN, mNumScreens);
185         }
186     }
187 
188     @AnyThread
updateOffset()189     private void updateOffset() {
190         Message.obtain(mHandler, MSG_SET_NUM_PARALLAX, getNumPagesForWallpaperParallax(), 0,
191                 mWindowToken).sendToTarget();
192     }
193 
jumpToFinal()194     public void jumpToFinal() {
195         Message.obtain(mHandler, MSG_JUMP_TO_FINAL, mWindowToken).sendToTarget();
196     }
197 
setWindowToken(IBinder token)198     public void setWindowToken(IBinder token) {
199         mWindowToken = token;
200         if (mWindowToken == null && mRegistered) {
201             mWallpaperChangeReceiver.unregisterReceiverSafely(mWorkspace.getContext());
202             mRegistered = false;
203         } else if (mWindowToken != null && !mRegistered) {
204             mWallpaperChangeReceiver.register(mWorkspace.getContext(), ACTION_WALLPAPER_CHANGED);
205             onWallpaperChanged();
206             mRegistered = true;
207         }
208     }
209 
onWallpaperChanged()210     private void onWallpaperChanged() {
211         UI_HELPER_EXECUTOR.execute(() -> {
212             // Updating the boolean on a background thread is fine as the assignments are atomic
213             mWallpaperIsLiveWallpaper = WallpaperManager.getInstance(mWorkspace.getContext())
214                     .getWallpaperInfo() != null;
215             updateOffset();
216         });
217     }
218 
219     private static final int MSG_START_ANIMATION = 1;
220     private static final int MSG_UPDATE_OFFSET = 2;
221     private static final int MSG_APPLY_OFFSET = 3;
222     private static final int MSG_SET_NUM_PARALLAX = 4;
223     private static final int MSG_JUMP_TO_FINAL = 5;
224 
225     private static class OffsetHandler extends Handler {
226 
227         private final Interpolator mInterpolator;
228         private final WallpaperManager mWM;
229 
230         private float mCurrentOffset = 0.5f; // to force an initial update
231         private boolean mAnimating;
232         private long mAnimationStartTime;
233         private float mAnimationStartOffset;
234 
235         private float mFinalOffset;
236         private float mOffsetX;
237 
OffsetHandler(Context context)238         public OffsetHandler(Context context) {
239             super(UI_HELPER_EXECUTOR.getLooper());
240             mInterpolator = Interpolators.DECELERATE_1_5;
241             mWM = WallpaperManager.getInstance(context);
242         }
243 
244         @Override
handleMessage(Message msg)245         public void handleMessage(Message msg) {
246             final IBinder token = (IBinder) msg.obj;
247             if (token == null) {
248                 return;
249             }
250 
251             switch (msg.what) {
252                 case MSG_START_ANIMATION: {
253                     mAnimating = true;
254                     mAnimationStartOffset = mCurrentOffset;
255                     mAnimationStartTime = msg.getWhen();
256                     // Follow through
257                 }
258                 case MSG_UPDATE_OFFSET:
259                     mFinalOffset = ((float) msg.arg1) / msg.arg2;
260                     // Follow through
261                 case MSG_APPLY_OFFSET: {
262                     float oldOffset = mCurrentOffset;
263                     if (mAnimating) {
264                         long durationSinceAnimation = SystemClock.uptimeMillis()
265                                 - mAnimationStartTime;
266                         float t0 = durationSinceAnimation / (float) ANIMATION_DURATION;
267                         float t1 = mInterpolator.getInterpolation(t0);
268                         mCurrentOffset = mAnimationStartOffset +
269                                 (mFinalOffset - mAnimationStartOffset) * t1;
270                         mAnimating = durationSinceAnimation < ANIMATION_DURATION;
271                     } else {
272                         mCurrentOffset = mFinalOffset;
273                     }
274 
275                     if (Float.compare(mCurrentOffset, oldOffset) != 0) {
276                         setOffsetSafely(token);
277                         // Force the wallpaper offset steps to be set again, because another app
278                         // might have changed them
279                         mWM.setWallpaperOffsetSteps(mOffsetX, 1.0f);
280                     }
281                     if (mAnimating) {
282                         // If we are animating, keep updating the offset
283                         Message.obtain(this, MSG_APPLY_OFFSET, token).sendToTarget();
284                     }
285                     return;
286                 }
287                 case MSG_SET_NUM_PARALLAX: {
288                     // Set wallpaper offset steps (1 / (number of screens - 1))
289                     mOffsetX = 1.0f / (msg.arg1 - 1);
290                     mWM.setWallpaperOffsetSteps(mOffsetX, 1.0f);
291                     return;
292                 }
293                 case MSG_JUMP_TO_FINAL: {
294                     if (Float.compare(mCurrentOffset, mFinalOffset) != 0) {
295                         mCurrentOffset = mFinalOffset;
296                         setOffsetSafely(token);
297                     }
298                     mAnimating = false;
299                     return;
300                 }
301             }
302         }
303 
304         private void setOffsetSafely(IBinder token) {
305             try {
306                 mWM.setWallpaperOffsets(token, mCurrentOffset, 0.5f);
307             } catch (IllegalArgumentException e) {
308                 Log.e(TAG, "Error updating wallpaper offset: " + e);
309             }
310         }
311     }
312 }