SpecialEffectsController.java

/*
 * Copyright 2019 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.fragment.app;

import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.CancellationSignal;
import androidx.fragment.R;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

/**
 * Controller for all "special effects" (such as Animation, Animator, framework Transition, and
 * AndroidX Transition) that can be applied to a Fragment as part of the addition or removal
 * of that Fragment from its container.
 * <p>
 * Each SpecialEffectsController is responsible for a single {@link ViewGroup} container.
 */
abstract class SpecialEffectsController {

    /**
     * Get the {@link SpecialEffectsController} for a given container if it already exists
     * or create it. This will automatically find the containing FragmentManager and use the
     * factory provided by {@link FragmentManager#getSpecialEffectsControllerFactory()}.
     *
     * @param container ViewGroup to find the associated SpecialEffectsController for.
     * @return a SpecialEffectsController for the given container
     */
    @NonNull
    static SpecialEffectsController getOrCreateController(
            @NonNull ViewGroup container, @NonNull FragmentManager fragmentManager) {
        SpecialEffectsControllerFactory factory =
                fragmentManager.getSpecialEffectsControllerFactory();
        return getOrCreateController(container, factory);
    }

    /**
     * Get the {@link SpecialEffectsController} for a given container if it already exists
     * or create it using the given {@link SpecialEffectsControllerFactory} if it does not.
     *
     * @param container ViewGroup to find the associated SpecialEffectsController for.
     * @param factory The factory to use to create a new SpecialEffectsController if one does
     *                not already exist for this container.
     * @return a SpecialEffectsController for the given container
     */
    @NonNull
    static SpecialEffectsController getOrCreateController(
            @NonNull ViewGroup container,
            @NonNull SpecialEffectsControllerFactory factory) {
        Object controller = container.getTag(R.id.special_effects_controller_view_tag);
        if (controller instanceof SpecialEffectsController) {
            return (SpecialEffectsController) controller;
        }
        // Else, create a new SpecialEffectsController
        SpecialEffectsController newController = factory.createController(container);
        container.setTag(R.id.special_effects_controller_view_tag, newController);
        return newController;
    }

    private final ViewGroup mContainer;

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    final ArrayList<Operation> mPendingOperations = new ArrayList<>();
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    final HashMap<Fragment, Operation> mAwaitingCompletionOperations = new HashMap<>();

    boolean mOperationDirectionIsPop = false;
    boolean mIsContainerPostponed = false;

    SpecialEffectsController(@NonNull ViewGroup container) {
        mContainer = container;
    }

    @NonNull
    public ViewGroup getContainer() {
        return mContainer;
    }

    /**
     * Checks what {@link Operation.LifecycleImpact lifecycle impact} of special effect for the
     * given FragmentStateManager is still awaiting completion (or cancellation).
     * <p>
     * This could be because the Operation is still pending (and
     * {@link #executePendingOperations()} hasn't been called) or because the
     * controller hasn't called {@link Operation#complete()}.
     *
     * @param fragmentStateManager the FragmentStateManager to check for
     * @return The {@link Operation.LifecycleImpact} of the awaiting Operation, or null if there is
     * no special effects still in progress.
     */
    @Nullable
    Operation.LifecycleImpact getAwaitingCompletionLifecycleImpact(
            @NonNull FragmentStateManager fragmentStateManager) {
        Operation operation = mAwaitingCompletionOperations.get(
                fragmentStateManager.getFragment());
        if (operation != null && !operation.getCancellationSignal().isCanceled()) {
            return operation.getLifecycleImpact();
        }
        return null;
    }

    void enqueueAdd(@NonNull Operation.State finalState,
            @NonNull FragmentStateManager fragmentStateManager,
            @NonNull CancellationSignal cancellationSignal) {
        enqueue(finalState, Operation.LifecycleImpact.ADDING,
                fragmentStateManager, cancellationSignal);
    }

    void enqueueShow(@NonNull FragmentStateManager fragmentStateManager,
            @NonNull CancellationSignal cancellationSignal) {
        enqueue(Operation.State.VISIBLE, Operation.LifecycleImpact.NONE,
                fragmentStateManager, cancellationSignal);
    }

    void enqueueHide(@NonNull FragmentStateManager fragmentStateManager,
            @NonNull CancellationSignal cancellationSignal) {
        enqueue(Operation.State.GONE, Operation.LifecycleImpact.NONE,
                fragmentStateManager, cancellationSignal);
    }

    void enqueueRemove(@NonNull FragmentStateManager fragmentStateManager,
            @NonNull CancellationSignal cancellationSignal) {
        enqueue(Operation.State.REMOVED, Operation.LifecycleImpact.REMOVING,
                fragmentStateManager, cancellationSignal);
    }

    private void enqueue(@NonNull Operation.State finalState,
            @NonNull Operation.LifecycleImpact lifecycleImpact,
            @NonNull final FragmentStateManager fragmentStateManager,
            @NonNull CancellationSignal cancellationSignal) {
        if (cancellationSignal.isCanceled()) {
            // Ignore enqueue operations that are already cancelled
            return;
        }
        synchronized (mPendingOperations) {
            final CancellationSignal signal = new CancellationSignal();
            Operation existingOperation =
                    mAwaitingCompletionOperations.get(fragmentStateManager.getFragment());
            if (existingOperation != null) {
                // Update the existing operation by merging in the new information
                // rather than creating a new Operation entirely
                existingOperation.mergeWith(finalState, lifecycleImpact, cancellationSignal);
                return;
            }
            final FragmentStateManagerOperation operation = new FragmentStateManagerOperation(
                    finalState, lifecycleImpact, fragmentStateManager, signal);
            mPendingOperations.add(operation);
            mAwaitingCompletionOperations.put(operation.getFragment(), operation);
            // Ensure that pending operations are removed when cancelled
            cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() {
                @Override
                public void onCancel() {
                    synchronized (mPendingOperations) {
                        mPendingOperations.remove(operation);
                        mAwaitingCompletionOperations.remove(operation.getFragment());
                        signal.cancel();
                    }
                }
            });
            // Ensure that we remove the Operation from the list of
            // awaiting completion operations when the operation is complete
            operation.addCompletionListener(new Runnable() {
                @Override
                public void run() {
                    if (!operation.getCancellationSignal().isCanceled()) {
                        mAwaitingCompletionOperations.remove(operation.getFragment());
                    }
                }
            });
        }
    }

    void updateOperationDirection(boolean isPop) {
        mOperationDirectionIsPop = isPop;
    }

    void markPostponedState() {
        synchronized (mPendingOperations) {
            // Default to not postponed
            mIsContainerPostponed = false;
            for (int index = mPendingOperations.size() - 1; index >= 0; index--) {
                Operation operation = mPendingOperations.get(index);
                // Only consider operations with entering transitions
                Operation.State currentState = Operation.State.from(operation.getFragment().mView);
                if (operation.getFinalState() == Operation.State.VISIBLE
                        && currentState != Operation.State.VISIBLE) {
                    Fragment fragment = operation.getFragment();
                    // The container is considered postponed if the Fragment
                    // associated with the last entering Operation is postponed
                    mIsContainerPostponed = fragment.isPostponed();
                    break;
                }
            }
        }
    }

    void forcePostponedExecutePendingOperations() {
        if (mIsContainerPostponed) {
            mIsContainerPostponed = false;
            executePendingOperations();
        }
    }

    void executePendingOperations() {
        if (mIsContainerPostponed) {
            // No operations should execute while the container is postponed
            return;
        }
        synchronized (mPendingOperations) {
            if (!mPendingOperations.isEmpty()) {
                executeOperations(new ArrayList<>(mPendingOperations), mOperationDirectionIsPop);
                mPendingOperations.clear();
                mOperationDirectionIsPop = false;
            }
        }
    }

    void forceCompleteAllOperations() {
        synchronized (mPendingOperations) {
            for (Operation operation : mAwaitingCompletionOperations.values()) {
                operation.getCancellationSignal().cancel();
                operation.getFinalState().applyState(operation.getFragment().mView);
                operation.complete();
            }
            mAwaitingCompletionOperations.clear();
            // mPendingOperations is a subset of mAwaitingCompletionOperations
            // so cancellation is already done, we just need to clear out the operations
            mPendingOperations.clear();
        }
    }

    /**
     * Execute all of the given operations.
     * <p>
     * At a minimum, the SpecialEffectsController should call
     * {@link Operation#complete()} on each operation when all of the special effects
     * for the given Operation are complete.
     * <p>
     * It is <strong>strongly recommended</strong> that the SpecialEffectsController
     * should call {@link Operation#getCancellationSignal()} and listen for cancellation,
     * properly cancelling all special effects when the signal is cancelled.
     *
     * @param operations the list of operations to execute in order.
     * @param isPop whether this set of operations should be considered as triggered by a 'pop'.
     *              This can be used to control the direction of any special effects if they
     *              are not symmetric.
     */
    abstract void executeOperations(@NonNull List<Operation> operations, boolean isPop);

    /**
     * Class representing an ongoing special effects operation.
     *
     * @see #executeOperations(List, boolean)
     */
    static class Operation {

        /**
         * The state that the fragment's View should be in after applying this operation.
         *
         * @see #applyState(View)
         */
        enum State {
            /**
             * The fragment's view should be completely removed from the container.
             */
            REMOVED,
            /**
             * The fragment's view should be made {@link View#VISIBLE}.
             */
            VISIBLE,
            /**
             * The fragment's view should be made {@link View#GONE}.
             */
            GONE,
            /**
             * The fragment's view should be made {@link View#INVISIBLE}.
             */
            INVISIBLE;

            /**
             * Create a new State from the {@link View#getVisibility() view's visibility}.
             *
             * @param view The view to get the current visibility from.
             * @return A new State from the view's visibility.
             */
            @NonNull
            static State from(@NonNull View view) {
                return from(view.getVisibility());
            }

            /**
             * Create a new State from the visibility of a View.
             *
             * @param visibility The visibility constant to translate into a State.
             * @return A new State from the visibility.
             */
            @NonNull
            static State from(int visibility) {
                switch (visibility) {
                    case View.VISIBLE:
                        return VISIBLE;
                    case View.INVISIBLE:
                        return INVISIBLE;
                    case View.GONE:
                        return GONE;
                    default:
                        throw new IllegalArgumentException("Unknown visibility " + visibility);
                }
            }

            /**
             * Applies this state to the given View.
             *
             * @param view The View to apply this state to.
             */
            void applyState(@NonNull View view) {
                switch (this) {
                    case REMOVED:
                        ViewGroup parent = (ViewGroup) view.getParent();
                        if (parent != null) {
                            parent.removeView(view);
                        }
                        break;
                    case VISIBLE:
                        view.setVisibility(View.VISIBLE);
                        break;
                    case GONE:
                        view.setVisibility(View.GONE);
                        break;
                    case INVISIBLE:
                        view.setVisibility(View.INVISIBLE);
                        break;
                }
            }
        }

        /**
         * The impact that this operation has on the lifecycle of the fragment.
         */
        enum LifecycleImpact {
            /**
             * No impact on the fragment's lifecycle.
             */
            NONE,
            /**
             * This operation is associated with adding a fragment.
             */
            ADDING,
            /**
             * This operation is associated with removing a fragment.
             */
            REMOVING,
        }

        @NonNull
        private State mFinalState;
        @NonNull
        private LifecycleImpact mLifecycleImpact;
        @NonNull
        private final Fragment mFragment;
        @SuppressWarnings("WeakerAccess") /* synthetic access */
        @NonNull
        final CancellationSignal mCancellationSignal = new CancellationSignal();
        @NonNull
        private final List<Runnable> mCompletionListeners = new ArrayList<>();

        /**
         * Construct a new Operation.
         *
         * @param finalState What the final state after this operation should be.
         * @param lifecycleImpact The impact on the fragment's lifecycle.
         * @param fragment The Fragment being affected.
         * @param cancellationSignal A signal for handling cancellation
         */
        Operation(@NonNull State finalState, @NonNull LifecycleImpact lifecycleImpact,
                @NonNull Fragment fragment, @NonNull CancellationSignal cancellationSignal) {
            mFinalState = finalState;
            mLifecycleImpact = lifecycleImpact;
            mFragment = fragment;
            // Connect the CancellationSignal to our own
            cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() {
                @Override
                public void onCancel() {
                    mCancellationSignal.cancel();
                }
            });
        }

        /**
         * Returns what the final state after this operation should be.
         *
         * @return The final state after this operation should be.
         */
        @NonNull
        public State getFinalState() {
            return mFinalState;
        }

        /**
         * Returns how this Operation affects the lifecycle of the fragment.
         *
         * @return How this Operation affects the lifecycle of the fragment.
         */
        @NonNull
        LifecycleImpact getLifecycleImpact() {
            return mLifecycleImpact;
        }

        /**
         * The Fragment being added / removed.
         * @return An {@link Fragment#isAdded() added} Fragment.
         */
        @NonNull
        public final Fragment getFragment() {
            return mFragment;
        }

        /**
         * The {@link CancellationSignal} that signals that the operation should be
         * cancelled and any currently running special effects should be cancelled.
         *
         * @return A signal for handling cancellation
         */
        @NonNull
        public final CancellationSignal getCancellationSignal() {
            return mCancellationSignal;
        }

        final void mergeWith(@NonNull State finalState, @NonNull LifecycleImpact lifecycleImpact,
                @NonNull CancellationSignal cancellationSignal) {
            switch (lifecycleImpact) {
                case ADDING:
                    if (mFinalState == State.REMOVED) {
                        // Applying an ADDING operation to a REMOVED fragment
                        // moves it back to ADDING
                        mFinalState = State.VISIBLE;
                        mLifecycleImpact = LifecycleImpact.ADDING;
                    }
                    break;
                case REMOVING:
                    // Any REMOVING operation overrides whatever we had before
                    mFinalState = State.REMOVED;
                    mLifecycleImpact = LifecycleImpact.REMOVING;
                    break;
                case NONE:
                    // This is a hide or show operation
                    if (mFinalState != State.REMOVED) {
                        mFinalState = finalState;
                    }
            }
            // Connect the CancellationSignal to our own
            cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() {
                @Override
                public void onCancel() {
                    mCancellationSignal.cancel();
                }
            });
        }

        final void addCompletionListener(@NonNull Runnable listener) {
            mCompletionListeners.add(listener);
        }

        /**
         * Mark this Operation as complete. This should only be called when all
         * special effects associated with this Operation have completed successfully.
         */
        @CallSuper
        public void complete() {
            for (Runnable listener : mCompletionListeners) {
                listener.run();
            }
        }
    }

    private static class FragmentStateManagerOperation extends Operation {
        @NonNull
        private final FragmentStateManager mFragmentStateManager;

        FragmentStateManagerOperation(@NonNull State finalState,
                @NonNull LifecycleImpact lifecycleImpact,
                @NonNull FragmentStateManager fragmentStateManager,
                @NonNull CancellationSignal cancellationSignal) {
            super(finalState, lifecycleImpact, fragmentStateManager.getFragment(),
                    cancellationSignal);
            mFragmentStateManager = fragmentStateManager;
        }

        @Override
        public void complete() {
            super.complete();
            mFragmentStateManager.moveToExpectedState();
        }
    }
}