blob: 170aa63d3a414364154231f43c3d0e8c62ef7914 [file] [log] [blame]
/*
* Copyright (C) 2015 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 android.support.v17.leanback.app;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v17.leanback.animation.UntargetableAnimatorSet;
import android.support.v17.leanback.R;
import android.support.v17.leanback.widget.GuidanceStylist;
import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
import android.support.v17.leanback.widget.GuidedAction;
import android.support.v17.leanback.widget.GuidedActionsStylist;
import android.support.v17.leanback.widget.VerticalGridView;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
/**
* A GuidedStepFragment is used to guide the user through a decision or series of decisions.
* It is composed of a guidance view on the left and a view on the right containing a list of
* possible actions.
* <p>
* <h3>Basic Usage</h3>
* <p>
* Clients of GuidedStepFragment typically create a custom subclass to attach to their Activities.
* This custom subclass provides the information necessary to construct the user interface and
* respond to user actions. At a minimum, subclasses should override:
* <ul>
* <li>{@link #onCreateGuidance}, to provide instructions to the user</li>
* <li>{@link #onCreateActions}, to provide a set of {@link GuidedAction}s the user can take</li>
* <li>{@link #onGuidedActionClicked}, to respond to those actions</li>
* </ul>
* <p>
* <h3>Theming and Stylists</h3>
* <p>
* GuidedStepFragment delegates its visual styling to classes called stylists. The {@link
* GuidanceStylist} is responsible for the left guidance view, while the {@link
* GuidedActionsStylist} is responsible for the right actions view. The stylists use theme
* attributes to derive values associated with the presentation, such as colors, animations, etc.
* Most simple visual aspects of GuidanceStylist and GuidedActionsStylist can be customized
* via theming; see their documentation for more information.
* <p>
* GuidedStepFragments must have access to an appropriate theme in order for the stylists to
* function properly. Specifically, the fragment must receive {@link
* android.support.v17.leanback.R.style#Theme_Leanback_GuidedStep}, or a theme whose parent is
* is set to that theme. Themes can be provided in one of three ways:
* <ul>
* <li>The simplest way is to set the theme for the host Activity to the GuidedStep theme or a
* theme that derives from it.</li>
* <li>If the Activity already has a theme and setting its parent theme is inconvenient, the
* existing Activity theme can have an entry added for the attribute {@link
* android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedStepTheme}. If present,
* this theme will be used by GuidedStepFragment as an overlay to the Activity's theme.</li>
* <li>Finally, custom subclasses of GuidedStepFragment may provide a theme through the {@link
* #onProvideTheme} method. This can be useful if a subclass is used across multiple
* Activities.</li>
* </ul>
* <p>
* If the theme is provided in multiple ways, the onProvideTheme override has priority, followed by
* the Activty's theme. (Themes whose parent theme is already set to the guided step theme do not
* need to set the guidedStepTheme attribute; if set, it will be ignored.)
* <p>
* If themes do not provide enough customizability, the stylists themselves may be subclassed and
* provided to the GuidedStepFragment through the {@link #onCreateGuidanceStylist} and {@link
* #onCreateActionsStylist} methods. The stylists have simple hooks so that subclasses
* may override layout files; subclasses may also have more complex logic to determine styling.
* <p>
* <h3>Guided sequences</h3>
* <p>
* GuidedStepFragments can be grouped together to provide a guided sequence. GuidedStepFragments
* grouped as a sequence use custom animations provided by {@link GuidanceStylist} and
* {@link GuidedActionsStylist} (or subclasses) during transitions between steps. Clients
* should use {@link #add} to place subsequent GuidedFragments onto the fragment stack so that
* custom animations are properly configured. (Custom animations are triggered automatically when
* the fragment stack is subsequently popped by any normal mechanism.)
* <p>
* <i>Note: Currently GuidedStepFragments grouped in this way must all be defined programmatically,
* rather than in XML. This restriction may be removed in the future.</i>
* <p>
* @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedStepTheme
* @see GuidanceStylist
* @see GuidanceStylist.Guidance
* @see GuidedAction
* @see GuidedActionsStylist
*/
public class GuidedStepFragment extends Fragment implements GuidedActionAdapter.ClickListener,
GuidedActionAdapter.FocusListener {
private static final String TAG_LEAN_BACK_ACTIONS_FRAGMENT = "leanBackGuidedStepFragment";
private static final String EXTRA_ACTION_SELECTED_INDEX = "selectedIndex";
private static final String EXTRA_ACTION_ENTRY_TRANSITION_ENABLED = "entryTransitionEnabled";
private static final String EXTRA_ENTRY_TRANSITION_PERFORMED = "entryTransitionPerformed";
private static final String TAG = "GuidedStepFragment";
private static final boolean DEBUG = true;
private static final int ANIMATION_FRAGMENT_ENTER = 1;
private static final int ANIMATION_FRAGMENT_EXIT = 2;
private static final int ANIMATION_FRAGMENT_ENTER_POP = 3;
private static final int ANIMATION_FRAGMENT_EXIT_POP = 4;
private int mTheme;
private GuidanceStylist mGuidanceStylist;
private GuidedActionsStylist mActionsStylist;
private GuidedActionAdapter mAdapter;
private VerticalGridView mListView;
private List<GuidedAction> mActions = new ArrayList<GuidedAction>();
private int mSelectedIndex = -1;
private boolean mEntryTransitionPerformed;
private boolean mEntryTransitionEnabled = true;
public GuidedStepFragment() {
// We need to supply the theme before any potential call to onInflate in order
// for the defaulting to work properly.
mTheme = onProvideTheme();
mGuidanceStylist = onCreateGuidanceStylist();
mActionsStylist = onCreateActionsStylist();
}
/**
* Creates the presenter used to style the guidance panel. The default implementation returns
* a basic GuidanceStylist.
* @return The GuidanceStylist used in this fragment.
*/
public GuidanceStylist onCreateGuidanceStylist() {
return new GuidanceStylist();
}
/**
* Creates the presenter used to style the guided actions panel. The default implementation
* returns a basic GuidedActionsStylist.
* @return The GuidedActionsStylist used in this fragment.
*/
public GuidedActionsStylist onCreateActionsStylist() {
return new GuidedActionsStylist();
}
/**
* Returns the theme used for styling the fragment. The default returns -1, indicating that the
* host Activity's theme should be used.
* @return The theme resource ID of the theme to use in this fragment, or -1 to use the
* host Activity's theme.
*/
public int onProvideTheme() {
return -1;
}
/**
* Returns the information required to provide guidance to the user. This hook is called during
* {@link #onCreateView}. May be overridden to return a custom subclass of {@link
* GuidanceStylist.Guidance} for use in a subclass of {@link GuidanceStylist}. The default
* returns a Guidance object with empty fields; subclasses should override.
* @param savedInstanceState The saved instance state from onCreateView.
* @return The Guidance object representing the information used to guide the user.
*/
public @NonNull Guidance onCreateGuidance(Bundle savedInstanceState) {
return new Guidance("", "", "", null);
}
/**
* Fills out the set of actions available to the user. This hook is called during {@link
* #onCreate}. The default leaves the list of actions empty; subclasses should override.
* @param actions A non-null, empty list ready to be populated.
* @param savedInstanceState The saved instance state from onCreate.
*/
public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
}
/**
* Callback invoked when an action is taken by the user. Subclasses should override in
* order to act on the user's decisions.
* @param action The chosen action.
*/
@Override
public void onGuidedActionClicked(GuidedAction action) {
}
/**
* Callback invoked when an action is focused (made to be the current selection) by the user.
*/
@Override
public void onGuidedActionFocused(GuidedAction action) {
}
/**
* Callback invoked when an action's title has been edited.
*/
public void onGuidedActionEdited(GuidedAction action) {
}
/**
* Adds the specified GuidedStepFragment to the fragment stack, replacing any existing
* GuidedStepFragments in the stack, and configuring the fragment-to-fragment custom animations.
* <p>
* Note: currently fragments added using this method must be created programmatically rather
* than via XML.
* @param fragmentManager The FragmentManager to be used in the transaction.
* @param fragment The GuidedStepFragment to be inserted into the fragment stack.
* @return The ID returned by the call FragmentTransaction.replace.
*/
public static int add(FragmentManager fragmentManager, GuidedStepFragment fragment) {
return add(fragmentManager, fragment, android.R.id.content);
}
// Note, this method used to be public, but I haven't found a good way for a client
// to specify an id.
private static int add(FragmentManager fm, GuidedStepFragment f, int id) {
boolean inGuidedStep = getCurrentGuidedStepFragment(fm) != null;
FragmentTransaction ft = fm.beginTransaction();
if (inGuidedStep) {
ft.setCustomAnimations(ANIMATION_FRAGMENT_ENTER,
ANIMATION_FRAGMENT_EXIT, ANIMATION_FRAGMENT_ENTER_POP,
ANIMATION_FRAGMENT_EXIT_POP);
ft.addToBackStack(null);
}
return ft.replace(id, f, TAG_LEAN_BACK_ACTIONS_FRAGMENT).commit();
}
/**
* Returns the current GuidedStepFragment on the fragment transaction stack.
* @return The current GuidedStepFragment, if any, on the fragment transaction stack.
*/
public static GuidedStepFragment getCurrentGuidedStepFragment(FragmentManager fm) {
Fragment f = fm.findFragmentByTag(TAG_LEAN_BACK_ACTIONS_FRAGMENT);
if (f instanceof GuidedStepFragment) {
return (GuidedStepFragment) f;
}
return null;
}
/**
* Returns the GuidanceStylist that displays guidance information for the user.
* @return The GuidanceStylist for this fragment.
*/
public GuidanceStylist getGuidanceStylist() {
return mGuidanceStylist;
}
/**
* Returns the GuidedActionsStylist that displays the actions the user may take.
* @return The GuidedActionsStylist for this fragment.
*/
public GuidedActionsStylist getGuidedActionsStylist() {
return mActionsStylist;
}
/**
* Returns the list of GuidedActions that the user may take in this fragment.
* @return The list of GuidedActions for this fragment.
*/
public List<GuidedAction> getActions() {
return mActions;
}
/**
* Sets the list of GuidedActions that the user may take in this fragment.
* @param actions The list of GuidedActions for this fragment.
*/
public void setActions(List<GuidedAction> actions) {
mActions = actions;
if (mAdapter != null) {
mAdapter.setActions(mActions);
}
}
/**
* Returns the view corresponding to the action at the indicated position in the list of
* actions for this fragment.
* @param position The integer position of the action of interest.
* @return The View corresponding to the action at the indicated position, or null if that
* action is not currently onscreen.
*/
public View getActionItemView(int position) {
return mListView.findViewHolderForPosition(position).itemView;
}
/**
* Scrolls the action list to the position indicated, selecting that action's view.
* @param position The integer position of the action of interest.
*/
public void setSelectedActionPosition(int position) {
mListView.setSelectedPosition(position);
}
/**
* Returns the position if the currently selected GuidedAction.
* @return position The integer position of the currently selected action.
*/
public int getSelectedActionPosition() {
return mListView.getSelectedPosition();
}
/**
* {@inheritDoc}
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (DEBUG) Log.v(TAG, "onCreate");
Bundle state = (savedInstanceState != null) ? savedInstanceState : getArguments();
if (state != null) {
if (mSelectedIndex == -1) {
mSelectedIndex = state.getInt(EXTRA_ACTION_SELECTED_INDEX, -1);
}
mEntryTransitionEnabled = state.getBoolean(EXTRA_ACTION_ENTRY_TRANSITION_ENABLED, true);
mEntryTransitionPerformed = state.getBoolean(EXTRA_ENTRY_TRANSITION_PERFORMED, false);
}
mActions.clear();
onCreateActions(mActions, savedInstanceState);
}
/**
* {@inheritDoc}
*/
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
if (DEBUG) Log.v(TAG, "onCreateView");
resolveTheme();
inflater = getThemeInflater(inflater);
View v = inflater.inflate(R.layout.lb_guidedstep_fragment, container, false);
ViewGroup guidanceContainer = (ViewGroup) v.findViewById(R.id.content_fragment);
ViewGroup actionContainer = (ViewGroup) v.findViewById(R.id.action_fragment);
Guidance guidance = onCreateGuidance(savedInstanceState);
View guidanceView = mGuidanceStylist.onCreateView(inflater, guidanceContainer, guidance);
guidanceContainer.addView(guidanceView);
View actionsView = mActionsStylist.onCreateView(inflater, actionContainer);
actionContainer.addView(actionsView);
GuidedActionAdapter.EditListener editListener = new GuidedActionAdapter.EditListener() {
@Override
public void onGuidedActionEdited(GuidedAction action, boolean entering) {
runImeAnimations(entering);
if (!entering) {
GuidedStepFragment.this.onGuidedActionEdited(action);
}
}
};
mAdapter = new GuidedActionAdapter(mActions, this, this, editListener, mActionsStylist);
mListView = mActionsStylist.getActionsGridView();
mListView.setAdapter(mAdapter);
int pos = (mSelectedIndex >= 0 && mSelectedIndex < mActions.size()) ?
mSelectedIndex : getFirstCheckedAction();
mListView.setSelectedPosition(pos);
return v;
}
/**
* {@inheritDoc}
*/
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(EXTRA_ACTION_SELECTED_INDEX,
(mListView != null) ? getSelectedActionPosition() : mSelectedIndex);
outState.putBoolean(EXTRA_ACTION_ENTRY_TRANSITION_ENABLED, mEntryTransitionEnabled);
outState.putBoolean(EXTRA_ENTRY_TRANSITION_PERFORMED, mEntryTransitionPerformed);
}
/**
* {@inheritDoc}
*/
@Override
public void onStart() {
if (DEBUG) Log.v(TAG, "onStart");
super.onStart();
if (isEntryTransitionEnabled() && !mEntryTransitionPerformed) {
mEntryTransitionPerformed = true;
performEntryTransition();
}
}
/**
* {@inheritDoc}
*/
@Override
public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
if (DEBUG) Log.v(TAG, "onCreateAnimator: " + transit + " " + enter + " " + nextAnim);
View mainView = getView();
ArrayList<Animator> animators = new ArrayList<Animator>();
switch (nextAnim) {
case ANIMATION_FRAGMENT_ENTER:
mGuidanceStylist.onFragmentEnter(animators);
mActionsStylist.onFragmentEnter(animators);
break;
case ANIMATION_FRAGMENT_EXIT:
mGuidanceStylist.onFragmentExit(animators);
mActionsStylist.onFragmentExit(animators);
break;
case ANIMATION_FRAGMENT_ENTER_POP:
mGuidanceStylist.onFragmentReenter(animators);
mActionsStylist.onFragmentReenter(animators);
break;
case ANIMATION_FRAGMENT_EXIT_POP:
mGuidanceStylist.onFragmentReturn(animators);
mActionsStylist.onFragmentReturn(animators);
break;
default:
return super.onCreateAnimator(transit, enter, nextAnim);
}
mEntryTransitionPerformed = true;
return createDummyAnimator(mainView, animators);
}
/**
* Returns whether entry transitions are enabled for this fragment.
* @return Whether entry transitions are enabled for this fragment.
*/
protected boolean isEntryTransitionEnabled() {
return mEntryTransitionEnabled;
}
/**
* Sets whether entry transitions are enabled for this fragment.
* @param enabled Whether to enable entry transitions for this fragment.
*/
protected void setEntryTransitionEnabled(boolean enabled) {
mEntryTransitionEnabled = enabled;
}
private boolean isGuidedStepTheme(Context context) {
int resId = R.attr.guidedStepThemeFlag;
TypedValue typedValue = new TypedValue();
boolean found = context.getTheme().resolveAttribute(resId, typedValue, true);
if (DEBUG) Log.v(TAG, "Found guided step theme flag? " + found);
return found && typedValue.type == TypedValue.TYPE_INT_BOOLEAN && typedValue.data != 0;
}
private void resolveTheme() {
boolean hasThemeReference = true;
// Look up the guidedStepTheme in the currently specified theme. If it exists,
// replace the theme with its value.
Activity activity = getActivity();
if (mTheme == -1 && !isGuidedStepTheme(activity)) {
// Look up the guidedStepTheme in the activity's currently specified theme. If it
// exists, replace the theme with its value.
int resId = R.attr.guidedStepTheme;
TypedValue typedValue = new TypedValue();
boolean found = activity.getTheme().resolveAttribute(resId, typedValue, true);
if (DEBUG) Log.v(TAG, "Found guided step theme reference? " + found);
if (found) {
if (isGuidedStepTheme(new ContextThemeWrapper(activity, typedValue.resourceId))) {
mTheme = typedValue.resourceId;
} else {
found = false;
}
}
if (!found) {
Log.e(TAG, "GuidedStepFragment does not have an appropriate theme set.");
}
}
}
private LayoutInflater getThemeInflater(LayoutInflater inflater) {
if (mTheme == -1) {
return inflater;
} else {
Context ctw = new ContextThemeWrapper(getActivity(), mTheme);
return inflater.cloneInContext(ctw);
}
}
private int getFirstCheckedAction() {
for (int i = 0, size = mActions.size(); i < size; i++) {
if (mActions.get(i).isChecked()) {
return i;
}
}
return 0;
}
private void performEntryTransition() {
if (DEBUG) Log.v(TAG, "performEntryTransition");
final View mainView = getView();
mainView.setVisibility(View.INVISIBLE);
ArrayList<Animator> animators = new ArrayList<Animator>();
mGuidanceStylist.onActivityEnter(animators);
mActionsStylist.onActivityEnter(animators);
final Animator animator = createDummyAnimator(mainView, animators);
// We need to defer the animation until the first layout has occurred, as we don't yet
// know the final locations of views.
mainView.getViewTreeObserver().addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
mainView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
if (!isAdded()) {
// We have been detached before this could run,
// so just bail
return;
}
mainView.setVisibility(View.VISIBLE);
animator.start();
}
});
}
private void runImeAnimations(boolean entering) {
ArrayList<Animator> animators = new ArrayList<Animator>();
if (entering) {
mGuidanceStylist.onImeAppearing(animators);
mActionsStylist.onImeAppearing(animators);
} else {
mGuidanceStylist.onImeDisappearing(animators);
mActionsStylist.onImeDisappearing(animators);
}
AnimatorSet set = new AnimatorSet();
set.playTogether(animators);
set.start();
}
private Animator createDummyAnimator(final View v, ArrayList<Animator> animators) {
final AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(animators);
return new UntargetableAnimatorSet(animatorSet);
}
}