1 /*
2  * Copyright (C) 2017 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 
17 package android.support.wear.widget;
18 
19 import android.content.Context;
20 import android.support.annotation.UiThread;
21 import android.util.AttributeSet;
22 import android.util.Log;
23 import android.view.View;
24 import android.view.animation.AccelerateInterpolator;
25 import android.view.animation.DecelerateInterpolator;
26 
27 import java.util.ArrayList;
28 
29 /**
30  * A layout enabling left-to-right swipe-to-dismiss, intended for use within an activity.
31  *
32  * <p>At least one listener must be {@link #addCallback(Callback) added} to act on a dismissal
33  * action. A listener will typically remove a containing view or fragment from the current
34  * activity.
35  *
36  * <p>To suppress a swipe-dismiss gesture, at least one contained view must be scrollable,
37  * indicating that it would like to consume any horizontal touch gestures in that direction. In
38  * this  case this view will only allow swipe-to-dismiss on the very edge of the left-hand-side of
39  * the screen. If you wish to entirely disable the swipe-to-dismiss gesture,
40  * {@link #setSwipeable(boolean)} can be used for more direct control over the feature.
41  */
42 @UiThread
43 public class SwipeDismissFrameLayout extends SwipeDismissLayout {
44 
45     private static final String TAG = "SwipeDismissFrameLayout";
46 
47     private static final float TRANSLATION_MIN_ALPHA = 0.5f;
48     private static final float DEFAULT_INTERPOLATION_FACTOR = 1.5f;
49 
50     /** Implement this callback to act on particular stages of the dismissal. */
51     @UiThread
52     public abstract static class Callback {
53         /**
54          * Notifies listeners that the view is now considering to start a dismiss gesture from a
55          * particular point on the screen. The default implementation returns true for all
56          * coordinates so that is is possible to start a swipe-to-dismiss gesture from any location.
57          * If any one instance of this Callback returns false for a given set of coordinates,
58          * swipe-to-dismiss will not be allowed to start in that point.
59          *
60          * @param layout The layout associated with this callback.
61          * @param xDown The x coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
62          *              event for this motion.
63          * @param yDown The y coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
64          *              event for this motion.
65          * @return true if this gesture should be recognized as a swipe to dismiss gesture, false
66          * otherwise.
67          */
onPreSwipeStart(SwipeDismissFrameLayout layout, float xDown, float yDown)68         boolean onPreSwipeStart(SwipeDismissFrameLayout layout, float xDown, float yDown) {
69             return true;
70         }
71 
72         /**
73          * Notifies listeners that the view is now being dragged as part of a dismiss gesture.
74          *
75          * @param layout The layout associated with this callback.
76         */
onSwipeStarted(SwipeDismissFrameLayout layout)77         public void onSwipeStarted(SwipeDismissFrameLayout layout) {
78         }
79 
80         /**
81          * Notifies listeners that the swipe gesture has ended without a dismissal.
82          *
83          * @param layout The layout associated with this callback.
84          */
onSwipeCanceled(SwipeDismissFrameLayout layout)85         public void onSwipeCanceled(SwipeDismissFrameLayout layout) {
86         }
87 
88         /**
89          * Notifies listeners the dismissal is complete and the view now off screen.
90          *
91          * @param layout The layout associated with this callback.
92          */
onDismissed(SwipeDismissFrameLayout layout)93         public void onDismissed(SwipeDismissFrameLayout layout) {
94         }
95     }
96 
97     private final OnPreSwipeListener mOnPreSwipeListener = new MyOnPreSwipeListener();
98     private final OnDismissedListener mOnDismissedListener = new MyOnDismissedListener();
99 
100     private final OnSwipeProgressChangedListener mOnSwipeProgressListener =
101             new MyOnSwipeProgressChangedListener();
102 
103     private final ArrayList<Callback> mCallbacks = new ArrayList<>();
104     private final int mAnimationTime;
105     private final DecelerateInterpolator mCancelInterpolator;
106     private final AccelerateInterpolator mDismissInterpolator;
107     private final DecelerateInterpolator mCompleteDismissGestureInterpolator;
108 
109     private boolean mStarted;
110 
111     /**
112      * Simple constructor to use when creating a view from code.
113      *
114      * @param context The {@link Context} the view is running in, through which it can access the
115      *                current theme, resources, etc.
116      */
SwipeDismissFrameLayout(Context context)117     public SwipeDismissFrameLayout(Context context) {
118         this(context, null, 0);
119     }
120 
121     /**
122      * Constructor that is called when inflating a view from XML. This is called when a view is
123      * being constructed from an XML file, supplying attributes that were specified in the XML file.
124      * This version uses a default style of 0, so the only attribute values applied are those in the
125      * Context's Theme and the given AttributeSet.
126      *
127      * <p>
128      *
129      * <p>The method onFinishInflate() will be called after all children have been added.
130      *
131      * @param context The {@link Context} the view is running in, through which it can access the
132      *                current theme, resources, etc.
133      * @param attrs   The attributes of the XML tag that is inflating the view.
134      */
SwipeDismissFrameLayout(Context context, AttributeSet attrs)135     public SwipeDismissFrameLayout(Context context, AttributeSet attrs) {
136         this(context, attrs, 0);
137     }
138 
139     /**
140      * Perform inflation from XML and apply a class-specific base style from a theme attribute.
141      * This constructor allows subclasses to use their own base style when they are inflating.
142      *
143      * @param context  The {@link Context} the view is running in, through which it can access the
144      *                 current theme, resources, etc.
145      * @param attrs    The attributes of the XML tag that is inflating the view.
146      * @param defStyle An attribute in the current theme that contains a reference to a style
147      *                 resource that supplies default values for the view. Can be 0 to not look for
148      *                 defaults.
149      */
SwipeDismissFrameLayout(Context context, AttributeSet attrs, int defStyle)150     public SwipeDismissFrameLayout(Context context, AttributeSet attrs, int defStyle) {
151         this(context, attrs, defStyle, 0);
152     }
153 
154     /**
155      * Perform inflation from XML and apply a class-specific base style from a theme attribute.
156      * This constructor allows subclasses to use their own base style when they are inflating.
157      *
158      * @param context  The {@link Context} the view is running in, through which it can access the
159      *                 current theme, resources, etc.
160      * @param attrs    The attributes of the XML tag that is inflating the view.
161      * @param defStyle An attribute in the current theme that contains a reference to a style
162      *                 resource that supplies default values for the view. Can be 0 to not look for
163      *                 defaults.
164      * @param defStyleRes This corresponds to the fourth argument
165      *                    of {@link View#View(Context, AttributeSet, int, int)}. It allows a style
166      *                    resource to be specified when creating the view.
167      */
SwipeDismissFrameLayout(Context context, AttributeSet attrs, int defStyle, int defStyleRes)168     public SwipeDismissFrameLayout(Context context, AttributeSet attrs, int defStyle,
169             int defStyleRes) {
170         super(context, attrs, defStyle, defStyleRes);
171         setOnPreSwipeListener(mOnPreSwipeListener);
172         setOnDismissedListener(mOnDismissedListener);
173         setOnSwipeProgressChangedListener(mOnSwipeProgressListener);
174         mAnimationTime = getContext().getResources().getInteger(
175                 android.R.integer.config_shortAnimTime);
176         mCancelInterpolator = new DecelerateInterpolator(DEFAULT_INTERPOLATION_FACTOR);
177         mDismissInterpolator = new AccelerateInterpolator(DEFAULT_INTERPOLATION_FACTOR);
178         mCompleteDismissGestureInterpolator = new DecelerateInterpolator(
179                 DEFAULT_INTERPOLATION_FACTOR);
180     }
181 
182     /** Adds a callback for dismissal. */
addCallback(Callback callback)183     public void addCallback(Callback callback) {
184         if (callback == null) {
185             throw new NullPointerException("addCallback called with null callback");
186         }
187         mCallbacks.add(callback);
188     }
189 
190     /** Removes a callback that was added with {@link #addCallback(Callback)}. */
removeCallback(Callback callback)191     public void removeCallback(Callback callback) {
192         if (callback == null) {
193             throw new NullPointerException("removeCallback called with null callback");
194         }
195         if (!mCallbacks.remove(callback)) {
196             throw new IllegalStateException("removeCallback called with nonexistent callback");
197         }
198     }
199 
200     /**
201      * Resets this view to the original state. This method cancels any pending animations on this
202      * view and resets the alpha as well as x translation values.
203      */
resetTranslationAndAlpha()204     private void resetTranslationAndAlpha() {
205         animate().cancel();
206         setTranslationX(0);
207         setAlpha(1);
208         mStarted = false;
209     }
210 
211     private final class MyOnPreSwipeListener implements OnPreSwipeListener {
212 
213         @Override
onPreSwipe(SwipeDismissLayout layout, float xDown, float yDown)214         public boolean onPreSwipe(SwipeDismissLayout layout, float xDown, float yDown) {
215             for (Callback callback : mCallbacks) {
216                 if (!callback.onPreSwipeStart(SwipeDismissFrameLayout.this, xDown, yDown)) {
217                     return false;
218                 }
219             }
220             return true;
221         }
222     }
223 
224     private final class MyOnDismissedListener implements OnDismissedListener {
225 
226         @Override
onDismissed(SwipeDismissLayout layout)227         public void onDismissed(SwipeDismissLayout layout) {
228             if (Log.isLoggable(TAG, Log.DEBUG)) {
229                 Log.d(TAG, "onDismissed()");
230             }
231             animate()
232                     .translationX(getWidth())
233                     .alpha(0)
234                     .setDuration(mAnimationTime)
235                     .setInterpolator(
236                             mStarted ? mCompleteDismissGestureInterpolator : mDismissInterpolator)
237                     .withEndAction(
238                             new Runnable() {
239                                 @Override
240                                 public void run() {
241                                     for (int i = mCallbacks.size() - 1; i >= 0; i--) {
242                                         Callback callbacks = mCallbacks.get(i);
243                                         callbacks.onDismissed(SwipeDismissFrameLayout.this);
244                                     }
245                                     resetTranslationAndAlpha();
246                                 }
247                             });
248         }
249     }
250 
251     private final class MyOnSwipeProgressChangedListener implements OnSwipeProgressChangedListener {
252 
253         @Override
onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate)254         public void onSwipeProgressChanged(SwipeDismissLayout layout, float progress,
255                 float translate) {
256             if (Log.isLoggable(TAG, Log.DEBUG)) {
257                 Log.d(TAG, "onSwipeProgressChanged() - " + translate);
258             }
259             setTranslationX(translate);
260             setAlpha(1 - (progress * TRANSLATION_MIN_ALPHA));
261             if (!mStarted) {
262                 for (int i = mCallbacks.size() - 1; i >= 0; i--) {
263                     Callback callbacks = mCallbacks.get(i);
264                     callbacks.onSwipeStarted(SwipeDismissFrameLayout.this);
265                 }
266                 mStarted = true;
267             }
268         }
269 
270         @Override
onSwipeCanceled(SwipeDismissLayout layout)271         public void onSwipeCanceled(SwipeDismissLayout layout) {
272             if (Log.isLoggable(TAG, Log.DEBUG)) {
273                 Log.d(TAG, "onSwipeCanceled() run swipe cancel animation");
274             }
275             mStarted = false;
276             animate()
277                     .translationX(0)
278                     .alpha(1)
279                     .setDuration(mAnimationTime)
280                     .setInterpolator(mCancelInterpolator)
281                     .withEndAction(
282                             new Runnable() {
283                                 @Override
284                                 public void run() {
285                                     for (int i = mCallbacks.size() - 1; i >= 0; i--) {
286                                         Callback callbacks = mCallbacks.get(i);
287                                         callbacks.onSwipeCanceled(SwipeDismissFrameLayout.this);
288                                     }
289                                     resetTranslationAndAlpha();
290                                 }
291                             });
292         }
293     }
294 }
295