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
17package android.support.wear.widget;
18
19import android.content.Context;
20import android.support.annotation.UiThread;
21import android.util.AttributeSet;
22import android.util.Log;
23import android.view.View;
24import android.view.animation.AccelerateInterpolator;
25import android.view.animation.DecelerateInterpolator;
26
27import 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
43public 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         */
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        */
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         */
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         */
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     */
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     */
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     */
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     */
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. */
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)}. */
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     */
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
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
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
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
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