DismissibleFrameLayout.java

/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.wear.utils.WearableNavigationHelper;

import java.util.ArrayList;

/**
 * Special FrameLayout that is dismissible by the Back button press, and by left to right swipe
 * when the SwipeToDismiss is been enabled on the device config or themes.
 */
@UiThread
public class DismissibleFrameLayout extends FrameLayout {
    private static final String TAG = "DismissibleFrameLayout";

    /**
     * Implement this callback to act on particular stage of the dismissal.
     */
    @UiThread
    public abstract static class Callback {

        /**
         * Notifies listeners the dismissal is started.
         * @param layout The layout associated with this callback.
         */
        public void onDismissStarted(@NonNull DismissibleFrameLayout layout) {
        }

        /**
         * Notifies listeners the dismissal is canceled.
         *
         * Note, only valid on swipe-to-dismiss gesture. Dismissal triggered by back button press
         * can not be canceled.
         * @param layout The layout associated with this callback.
         */
        public void onDismissCanceled(@NonNull DismissibleFrameLayout layout) {
        }

        /**
         * Notifies listeners the dismissal is complete and the view now off screen.
         *
         * @param layout The layout associated with this callback.
         */
        public void onDismissed(@NonNull DismissibleFrameLayout layout) {
        }
    }

    private final Context mContext;
    private SwipeDismissController mSwipeDismissController = null;
    private BackButtonDismissController mBackButtonDismissController = null;
    private final MyDismissListener mDismissListener = new MyDismissListener();
    final ArrayList<Callback> mCallbacks = new ArrayList<>();

    /**
     * Simple constructor to use when creating a view from code.
     *
     * @param context The {@link Context} the view is running in, through which it can access the
     *                current theme, resources, etc.
     */
    public DismissibleFrameLayout(@NonNull Context context) {
        this(context, null);
    }

    /**
     * Constructor that is called when inflating a view from XML. This is called when a view is
     * being constructed from an XML file, supplying attributes that were specified in the XML file.
     * This version uses a default style of 0, so the only attribute values applied are those in the
     * Context's Theme and the given AttributeSet.
     *
     * <p>
     *
     * <p>The method onFinishInflate() will be called after all children have been added.
     *
     * @param context The {@link Context} the view is running in, through which it can access the
     *                current theme, resources, etc.
     * @param attrs   The attributes of the XML tag that is inflating the view.
     */
    public DismissibleFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    /**
     * Perform inflation from XML and apply a class-specific base style from a theme attribute.
     * This constructor allows subclasses to use their own base style when they are inflating.
     *
     * @param context  The {@link Context} the view is running in, through which it can access the
     *                 current theme, resources, etc.
     * @param attrs    The attributes of the XML tag that is inflating the view.
     * @param defStyle An attribute in the current theme that contains a reference to a style
     *                 resource that supplies default values for the view. Can be 0 to not look for
     *                 defaults.
     */
    public DismissibleFrameLayout(
            @NonNull Context context,
            @Nullable AttributeSet attrs,
            int defStyle) {
        this(context, attrs, defStyle, 0);
    }

    /**
     * Perform inflation from XML and apply a class-specific base style from a theme attribute.
     * This constructor allows subclasses to use their own base style when they are inflating.
     *
     * @param context  The {@link Context} the view is running in, through which it can access the
     *                 current theme, resources, etc.
     * @param attrs    The attributes of the XML tag that is inflating the view.
     * @param defStyle An attribute in the current theme that contains a reference to a style
     *                 resource that supplies default values for the view. Can be 0 to not look for
     *                 defaults.
     * @param defStyleRes It allows a style resource to be specified when creating the view.
     */
    public DismissibleFrameLayout(@NonNull Context context,
            @Nullable AttributeSet attrs,
            int defStyle,
            int defStyleRes) {
        super(context, attrs, defStyle, defStyleRes);

        mContext = context;

        setSwipeDismissible(WearableNavigationHelper.isSwipeToDismissEnabled(context));
        setBackButtonDismissible(true);
    }

    /** Registers a callback for dismissal. */
    @UiThread
    public void registerCallback(@NonNull Callback callback) {
        mCallbacks.add(callback);
    }

    /** Removes a callback that was added with {@link #registerCallback(Callback)}. */
    @UiThread
    public void unregisterCallback(@NonNull Callback callback) {
        if (!mCallbacks.remove(callback)) {
            throw new IllegalStateException("removeCallback called with nonexistent callback");
        }
    }

    /**
     * Sets the frame layout to be swipe dismissible or not.
     *
     * @param swipeDismissible whether the layout should react to the swipe gesture
     */
    public void setSwipeDismissible(boolean swipeDismissible) {
        if (swipeDismissible) {
            if (mSwipeDismissController == null) {
                mSwipeDismissController = new SwipeDismissController(mContext, this);
                mSwipeDismissController.setOnDismissListener(mDismissListener);
            }
        } else if (mSwipeDismissController != null) {
            mSwipeDismissController.setOnDismissListener(null);
            mSwipeDismissController = null;
        }
    }

    /** Returns true if the frame layout can be dismissed by swipe gestures. */
    public boolean isSwipeDismissible() {
        return mSwipeDismissController != null;
    }

    /**
     * Sets the frame layout to be back button dismissible or not.
     * @param backButtonDismissible boolean value to enable/disable the back button dismiss
     */
    public void setBackButtonDismissible(boolean backButtonDismissible) {
        if (backButtonDismissible) {
            if (mBackButtonDismissController == null) {
                mBackButtonDismissController = new BackButtonDismissController(mContext, this);
                mBackButtonDismissController.setOnDismissListener(mDismissListener);
            }
        } else if (mBackButtonDismissController != null) {
            mBackButtonDismissController.disable(this);
            mBackButtonDismissController = null;
        }
    }

    /** Returns true if the frame layout would be dismissed with back button click */
    public boolean isBackButtonDismissible()  {
        return mBackButtonDismissController != null;
    }

    @Nullable
    SwipeDismissController getSwipeDismissController() {
        return mSwipeDismissController;
    }

    protected void executeDismissedCallbacks() {
        for (int i = mCallbacks.size() - 1; i >= 0; i--) {
            mCallbacks.get(i).onDismissed(this);
        }
    }

    protected void executeDismissStartedCallbacks() {
        for (int i = mCallbacks.size() - 1; i >= 0; i--) {
            mCallbacks.get(i).onDismissStarted(this);
        }
    }

    protected void executeDismissCanceledCallbacks() {
        for (int i = mCallbacks.size() - 1; i >= 0; i--) {
            mCallbacks.get(i).onDismissCanceled(this);
        }
    }

    private final class MyDismissListener implements DismissController.OnDismissListener {
        MyDismissListener() {
        }

        @Override
        public void onDismissStarted() {
            executeDismissStartedCallbacks();
        }

        @Override
        public void onDismissCanceled() {
            executeDismissCanceledCallbacks();
        }

        @Override
        public void onDismissed() {
            executeDismissedCallbacks();
        }
    }

    /**
     * Following methods overriding are only required with swipe-to-dismiss
     * to handle touch event for detect swipe gesture.
     */

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        if (mSwipeDismissController != null) {
            mSwipeDismissController.requestDisallowInterceptTouchEvent(disallowIntercept);
        } else {
            super.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    }

    @Override
    public boolean onInterceptTouchEvent(@NonNull MotionEvent ev) {
        if (mSwipeDismissController != null) {
            return mSwipeDismissController.onInterceptTouchEvent(ev);
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean canScrollHorizontally(int direction) {
        if (mSwipeDismissController != null) {
            return mSwipeDismissController.canScrollHorizontally(direction);
        }
        return super.canScrollHorizontally(direction);
    }

    @Override
    @SuppressLint("ClickableViewAccessibility")
    public boolean onTouchEvent(@NonNull MotionEvent ev) {
        if (mSwipeDismissController != null
                && mSwipeDismissController.onTouchEvent(ev)) {
            return true;
        }
        return super.onTouchEvent(ev);
    }
}