1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software distributed under the License
10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11 * or implied. See the License for the specific language governing permissions and limitations under
12 * the License.
13 */
14package android.support.v17.leanback.app;
15
16import android.animation.Animator;
17import android.animation.AnimatorSet;
18import android.app.Activity;
19import android.app.Fragment;
20import android.app.FragmentManager;
21import android.app.FragmentTransaction;
22import android.content.Context;
23import android.content.res.TypedArray;
24import android.os.Bundle;
25import android.support.annotation.NonNull;
26import android.support.v17.leanback.animation.UntargetableAnimatorSet;
27import android.support.v17.leanback.R;
28import android.support.v17.leanback.widget.GuidanceStylist;
29import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
30import android.support.v17.leanback.widget.GuidedAction;
31import android.support.v17.leanback.widget.GuidedActionsStylist;
32import android.support.v17.leanback.widget.VerticalGridView;
33import android.util.AttributeSet;
34import android.util.Log;
35import android.util.TypedValue;
36import android.view.ContextThemeWrapper;
37import android.view.LayoutInflater;
38import android.view.View;
39import android.view.ViewGroup;
40import android.view.ViewTreeObserver;
41import android.widget.ImageView;
42import android.widget.RelativeLayout;
43import android.widget.TextView;
44
45import java.util.ArrayList;
46import java.util.List;
47
48/**
49 * A GuidedStepFragment is used to guide the user through a decision or series of decisions.
50 * It is composed of a guidance view on the left and a view on the right containing a list of
51 * possible actions.
52 * <p>
53 * <h3>Basic Usage</h3>
54 * <p>
55 * Clients of GuidedStepFragment typically create a custom subclass to attach to their Activities.
56 * This custom subclass provides the information necessary to construct the user interface and
57 * respond to user actions. At a minimum, subclasses should override:
58 * <ul>
59 * <li>{@link #onCreateGuidance}, to provide instructions to the user</li>
60 * <li>{@link #onCreateActions}, to provide a set of {@link GuidedAction}s the user can take</li>
61 * <li>{@link #onGuidedActionClicked}, to respond to those actions</li>
62 * </ul>
63 * <p>
64 * <h3>Theming and Stylists</h3>
65 * <p>
66 * GuidedStepFragment delegates its visual styling to classes called stylists. The {@link
67 * GuidanceStylist} is responsible for the left guidance view, while the {@link
68 * GuidedActionsStylist} is responsible for the right actions view. The stylists use theme
69 * attributes to derive values associated with the presentation, such as colors, animations, etc.
70 * Most simple visual aspects of GuidanceStylist and GuidedActionsStylist can be customized
71 * via theming; see their documentation for more information.
72 * <p>
73 * GuidedStepFragments must have access to an appropriate theme in order for the stylists to
74 * function properly.  Specifically, the fragment must receive {@link
75 * android.support.v17.leanback.R.style#Theme_Leanback_GuidedStep}, or a theme whose parent is
76 * is set to that theme. Themes can be provided in one of three ways:
77 * <ul>
78 * <li>The simplest way is to set the theme for the host Activity to the GuidedStep theme or a
79 * theme that derives from it.</li>
80 * <li>If the Activity already has a theme and setting its parent theme is inconvenient, the
81 * existing Activity theme can have an entry added for the attribute {@link
82 * android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedStepTheme}. If present,
83 * this theme will be used by GuidedStepFragment as an overlay to the Activity's theme.</li>
84 * <li>Finally, custom subclasses of GuidedStepFragment may provide a theme through the {@link
85 * #onProvideTheme} method. This can be useful if a subclass is used across multiple
86 * Activities.</li>
87 * </ul>
88 * <p>
89 * If the theme is provided in multiple ways, the onProvideTheme override has priority, followed by
90 * the Activty's theme.  (Themes whose parent theme is already set to the guided step theme do not
91 * need to set the guidedStepTheme attribute; if set, it will be ignored.)
92 * <p>
93 * If themes do not provide enough customizability, the stylists themselves may be subclassed and
94 * provided to the GuidedStepFragment through the {@link #onCreateGuidanceStylist} and {@link
95 * #onCreateActionsStylist} methods.  The stylists have simple hooks so that subclasses
96 * may override layout files; subclasses may also have more complex logic to determine styling.
97 * <p>
98 * <h3>Guided sequences</h3>
99 * <p>
100 * GuidedStepFragments can be grouped together to provide a guided sequence. GuidedStepFragments
101 * grouped as a sequence use custom animations provided by {@link GuidanceStylist} and
102 * {@link GuidedActionsStylist} (or subclasses) during transitions between steps. Clients
103 * should use {@link #add} to place subsequent GuidedFragments onto the fragment stack so that
104 * custom animations are properly configured. (Custom animations are triggered automatically when
105 * the fragment stack is subsequently popped by any normal mechanism.)
106 * <p>
107 * <i>Note: Currently GuidedStepFragments grouped in this way must all be defined programmatically,
108 * rather than in XML. This restriction may be removed in the future.</i>
109 * <p>
110 * @attr ref android.support.v17.leanback.R.styleable#LeanbackGuidedStepTheme_guidedStepTheme
111 * @see GuidanceStylist
112 * @see GuidanceStylist.Guidance
113 * @see GuidedAction
114 * @see GuidedActionsStylist
115 */
116public class GuidedStepFragment extends Fragment implements GuidedActionAdapter.ClickListener,
117        GuidedActionAdapter.FocusListener {
118
119    private static final String TAG_LEAN_BACK_ACTIONS_FRAGMENT = "leanBackGuidedStepFragment";
120    private static final String EXTRA_ACTION_SELECTED_INDEX = "selectedIndex";
121    private static final String EXTRA_ACTION_ENTRY_TRANSITION_ENABLED = "entryTransitionEnabled";
122    private static final String EXTRA_ENTRY_TRANSITION_PERFORMED = "entryTransitionPerformed";
123    private static final String TAG = "GuidedStepFragment";
124    private static final boolean DEBUG = true;
125    private static final int ANIMATION_FRAGMENT_ENTER = 1;
126    private static final int ANIMATION_FRAGMENT_EXIT = 2;
127    private static final int ANIMATION_FRAGMENT_ENTER_POP = 3;
128    private static final int ANIMATION_FRAGMENT_EXIT_POP = 4;
129
130    private int mTheme;
131    private GuidanceStylist mGuidanceStylist;
132    private GuidedActionsStylist mActionsStylist;
133    private GuidedActionAdapter mAdapter;
134    private VerticalGridView mListView;
135    private List<GuidedAction> mActions = new ArrayList<GuidedAction>();
136    private int mSelectedIndex = -1;
137    private boolean mEntryTransitionPerformed;
138    private boolean mEntryTransitionEnabled = true;
139
140    public GuidedStepFragment() {
141        // We need to supply the theme before any potential call to onInflate in order
142        // for the defaulting to work properly.
143        mTheme = onProvideTheme();
144        mGuidanceStylist = onCreateGuidanceStylist();
145        mActionsStylist = onCreateActionsStylist();
146    }
147
148    /**
149     * Creates the presenter used to style the guidance panel. The default implementation returns
150     * a basic GuidanceStylist.
151     * @return The GuidanceStylist used in this fragment.
152     */
153    public GuidanceStylist onCreateGuidanceStylist() {
154        return new GuidanceStylist();
155    }
156
157    /**
158     * Creates the presenter used to style the guided actions panel. The default implementation
159     * returns a basic GuidedActionsStylist.
160     * @return The GuidedActionsStylist used in this fragment.
161     */
162    public GuidedActionsStylist onCreateActionsStylist() {
163        return new GuidedActionsStylist();
164    }
165
166    /**
167     * Returns the theme used for styling the fragment. The default returns -1, indicating that the
168     * host Activity's theme should be used.
169     * @return The theme resource ID of the theme to use in this fragment, or -1 to use the
170     * host Activity's theme.
171     */
172    public int onProvideTheme() {
173        return -1;
174    }
175
176    /**
177     * Returns the information required to provide guidance to the user. This hook is called during
178     * {@link #onCreateView}.  May be overridden to return a custom subclass of {@link
179     * GuidanceStylist.Guidance} for use in a subclass of {@link GuidanceStylist}. The default
180     * returns a Guidance object with empty fields; subclasses should override.
181     * @param savedInstanceState The saved instance state from onCreateView.
182     * @return The Guidance object representing the information used to guide the user.
183     */
184    public @NonNull Guidance onCreateGuidance(Bundle savedInstanceState) {
185        return new Guidance("", "", "", null);
186    }
187
188    /**
189     * Fills out the set of actions available to the user. This hook is called during {@link
190     * #onCreate}. The default leaves the list of actions empty; subclasses should override.
191     * @param actions A non-null, empty list ready to be populated.
192     * @param savedInstanceState The saved instance state from onCreate.
193     */
194    public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
195    }
196
197    /**
198     * Callback invoked when an action is taken by the user. Subclasses should override in
199     * order to act on the user's decisions.
200     * @param action The chosen action.
201     */
202    @Override
203    public void onGuidedActionClicked(GuidedAction action) {
204    }
205
206    /**
207     * Callback invoked when an action is focused (made to be the current selection) by the user.
208     */
209    @Override
210    public void onGuidedActionFocused(GuidedAction action) {
211    }
212
213    /**
214     * Adds the specified GuidedStepFragment to the fragment stack, replacing any existing
215     * GuidedStepFragments in the stack, and configuring the fragment-to-fragment custom animations.
216     * <p>
217     * Note: currently fragments added using this method must be created programmatically rather
218     * than via XML.
219     * @param fragmentManager The FragmentManager to be used in the transaction.
220     * @param fragment The GuidedStepFragment to be inserted into the fragment stack.
221     * @return The ID returned by the call FragmentTransaction.replace.
222     */
223    public static int add(FragmentManager fragmentManager, GuidedStepFragment fragment) {
224        return add(fragmentManager, fragment, android.R.id.content);
225    }
226
227    // Note, this method used to be public, but I haven't found a good way for a client
228    // to specify an id.
229    private static int add(FragmentManager fm, GuidedStepFragment f, int id) {
230        boolean inGuidedStep = getCurrentGuidedStepFragment(fm) != null;
231        FragmentTransaction ft = fm.beginTransaction();
232
233        if (inGuidedStep) {
234            ft.setCustomAnimations(ANIMATION_FRAGMENT_ENTER,
235                    ANIMATION_FRAGMENT_EXIT, ANIMATION_FRAGMENT_ENTER_POP,
236                    ANIMATION_FRAGMENT_EXIT_POP);
237            ft.addToBackStack(null);
238        }
239        return ft.replace(id, f, TAG_LEAN_BACK_ACTIONS_FRAGMENT).commit();
240    }
241
242    /**
243     * Returns the current GuidedStepFragment on the fragment transaction stack.
244     * @return The current GuidedStepFragment, if any, on the fragment transaction stack.
245     */
246    public static GuidedStepFragment getCurrentGuidedStepFragment(FragmentManager fm) {
247        Fragment f = fm.findFragmentByTag(TAG_LEAN_BACK_ACTIONS_FRAGMENT);
248        if (f instanceof GuidedStepFragment) {
249            return (GuidedStepFragment) f;
250        }
251        return null;
252    }
253
254    /**
255     * Returns the GuidanceStylist that displays guidance information for the user.
256     * @return The GuidanceStylist for this fragment.
257     */
258    public GuidanceStylist getGuidanceStylist() {
259        return mGuidanceStylist;
260    }
261
262    /**
263     * Returns the GuidedActionsStylist that displays the actions the user may take.
264     * @return The GuidedActionsStylist for this fragment.
265     */
266    public GuidedActionsStylist getGuidedActionsStylist() {
267        return mActionsStylist;
268    }
269
270    /**
271     * Returns the list of GuidedActions that the user may take in this fragment.
272     * @return The list of GuidedActions for this fragment.
273     */
274    public List<GuidedAction> getActions() {
275        return mActions;
276    }
277
278    /**
279     * Sets the list of GuidedActions that the user may take in this fragment.
280     * @param actions The list of GuidedActions for this fragment.
281     */
282    public void setActions(List<GuidedAction> actions) {
283        mActions = actions;
284        if (mAdapter != null) {
285            mAdapter.setActions(mActions);
286        }
287    }
288
289    /**
290     * Returns the view corresponding to the action at the indicated position in the list of
291     * actions for this fragment.
292     * @param position The integer position of the action of interest.
293     * @return The View corresponding to the action at the indicated position, or null if that
294     * action is not currently onscreen.
295     */
296    public View getActionItemView(int position) {
297        return mListView.findViewHolderForPosition(position).itemView;
298    }
299
300    /**
301     * Scrolls the action list to the position indicated, selecting that action's view.
302     * @param position The integer position of the action of interest.
303     */
304    public void setSelectedActionPosition(int position) {
305        mListView.setSelectedPosition(position);
306    }
307
308    /**
309     * Returns the position if the currently selected GuidedAction.
310     * @return position The integer position of the currently selected action.
311     */
312    public int getSelectedActionPosition() {
313        return mListView.getSelectedPosition();
314    }
315
316    /**
317     * {@inheritDoc}
318     */
319    @Override
320    public void onCreate(Bundle savedInstanceState) {
321        super.onCreate(savedInstanceState);
322        if (DEBUG) Log.v(TAG, "onCreate");
323        Bundle state = (savedInstanceState != null) ? savedInstanceState : getArguments();
324        if (state != null) {
325            if (mSelectedIndex == -1) {
326                mSelectedIndex = state.getInt(EXTRA_ACTION_SELECTED_INDEX, -1);
327            }
328            mEntryTransitionEnabled = state.getBoolean(EXTRA_ACTION_ENTRY_TRANSITION_ENABLED, true);
329            mEntryTransitionPerformed = state.getBoolean(EXTRA_ENTRY_TRANSITION_PERFORMED, false);
330        }
331        mActions.clear();
332        onCreateActions(mActions, savedInstanceState);
333    }
334
335    /**
336     * {@inheritDoc}
337     */
338    @Override
339    public View onCreateView(LayoutInflater inflater, ViewGroup container,
340            Bundle savedInstanceState) {
341        if (DEBUG) Log.v(TAG, "onCreateView");
342
343        resolveTheme();
344        inflater = getThemeInflater(inflater);
345
346        View v = inflater.inflate(R.layout.lb_guidedstep_fragment, container, false);
347        ViewGroup guidanceContainer = (ViewGroup) v.findViewById(R.id.content_fragment);
348        ViewGroup actionContainer = (ViewGroup) v.findViewById(R.id.action_fragment);
349
350        Guidance guidance = onCreateGuidance(savedInstanceState);
351        View guidanceView = mGuidanceStylist.onCreateView(inflater, guidanceContainer, guidance);
352        guidanceContainer.addView(guidanceView);
353
354        View actionsView = mActionsStylist.onCreateView(inflater, actionContainer);
355        actionContainer.addView(actionsView);
356
357        mAdapter = new GuidedActionAdapter(mActions, this, this, mActionsStylist);
358
359        mListView = mActionsStylist.getActionsGridView();
360        mListView.setAdapter(mAdapter);
361        int pos = (mSelectedIndex >= 0 && mSelectedIndex < mActions.size()) ?
362                mSelectedIndex : getFirstCheckedAction();
363        mListView.setSelectedPosition(pos);
364
365        return v;
366    }
367
368    /**
369     * {@inheritDoc}
370     */
371    @Override
372    public void onSaveInstanceState(Bundle outState) {
373        super.onSaveInstanceState(outState);
374        outState.putInt(EXTRA_ACTION_SELECTED_INDEX,
375                (mListView != null) ? getSelectedActionPosition() : mSelectedIndex);
376        outState.putBoolean(EXTRA_ACTION_ENTRY_TRANSITION_ENABLED, mEntryTransitionEnabled);
377        outState.putBoolean(EXTRA_ENTRY_TRANSITION_PERFORMED, mEntryTransitionPerformed);
378    }
379
380    /**
381     * {@inheritDoc}
382     */
383    @Override
384    public void onStart() {
385        if (DEBUG) Log.v(TAG, "onStart");
386        super.onStart();
387        if (isEntryTransitionEnabled() && !mEntryTransitionPerformed) {
388            mEntryTransitionPerformed = true;
389            performEntryTransition();
390        }
391    }
392
393    /**
394     * {@inheritDoc}
395     */
396    @Override
397    public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
398        if (DEBUG) Log.v(TAG, "onCreateAnimator: " + transit + " " + enter + " " + nextAnim);
399        View mainView = getView();
400
401        ArrayList<Animator> animators = new ArrayList<Animator>();
402        switch (nextAnim) {
403            case ANIMATION_FRAGMENT_ENTER:
404                mGuidanceStylist.onFragmentEnter(animators);
405                mActionsStylist.onFragmentEnter(animators);
406                break;
407            case ANIMATION_FRAGMENT_EXIT:
408                mGuidanceStylist.onFragmentExit(animators);
409                mActionsStylist.onFragmentExit(animators);
410                break;
411            case ANIMATION_FRAGMENT_ENTER_POP:
412                mGuidanceStylist.onFragmentReenter(animators);
413                mActionsStylist.onFragmentReenter(animators);
414                break;
415            case ANIMATION_FRAGMENT_EXIT_POP:
416                mGuidanceStylist.onFragmentReturn(animators);
417                mActionsStylist.onFragmentReturn(animators);
418                break;
419            default:
420                return super.onCreateAnimator(transit, enter, nextAnim);
421        }
422
423        mEntryTransitionPerformed = true;
424        return createDummyAnimator(mainView, animators);
425    }
426
427    /**
428     * Returns whether entry transitions are enabled for this fragment.
429     * @return Whether entry transitions are enabled for this fragment.
430     */
431    protected boolean isEntryTransitionEnabled() {
432        return mEntryTransitionEnabled;
433    }
434
435    /**
436     * Sets whether entry transitions are enabled for this fragment.
437     * @param enabled Whether to enable entry transitions for this fragment.
438     */
439    protected void setEntryTransitionEnabled(boolean enabled) {
440        mEntryTransitionEnabled = enabled;
441    }
442
443    private boolean isGuidedStepTheme(Context context) {
444        int resId = R.attr.guidedStepThemeFlag;
445        TypedValue typedValue = new TypedValue();
446        boolean found = context.getTheme().resolveAttribute(resId, typedValue, true);
447        if (DEBUG) Log.v(TAG, "Found guided step theme flag? " + found);
448        return found && typedValue.type == TypedValue.TYPE_INT_BOOLEAN && typedValue.data != 0;
449    }
450
451    private void resolveTheme() {
452        boolean hasThemeReference = true;
453        // Look up the guidedStepTheme in the currently specified theme.  If it exists,
454        // replace the theme with its value.
455        Activity activity = getActivity();
456        if (mTheme == -1 && !isGuidedStepTheme(activity)) {
457            // Look up the guidedStepTheme in the activity's currently specified theme.  If it
458            // exists, replace the theme with its value.
459            int resId = R.attr.guidedStepTheme;
460            TypedValue typedValue = new TypedValue();
461            boolean found = activity.getTheme().resolveAttribute(resId, typedValue, true);
462            if (DEBUG) Log.v(TAG, "Found guided step theme reference? " + found);
463            if (found) {
464                if (isGuidedStepTheme(new ContextThemeWrapper(activity, typedValue.resourceId))) {
465                    mTheme = typedValue.resourceId;
466                } else {
467                    found = false;
468                }
469            }
470            if (!found) {
471                Log.e(TAG, "GuidedStepFragment does not have an appropriate theme set.");
472            }
473        }
474    }
475
476    private LayoutInflater getThemeInflater(LayoutInflater inflater) {
477        if (mTheme == -1) {
478            return inflater;
479        } else {
480            Context ctw = new ContextThemeWrapper(getActivity(), mTheme);
481            return inflater.cloneInContext(ctw);
482        }
483    }
484
485    private int getFirstCheckedAction() {
486        for (int i = 0, size = mActions.size(); i < size; i++) {
487            if (mActions.get(i).isChecked()) {
488                return i;
489            }
490        }
491        return 0;
492    }
493
494    private void performEntryTransition() {
495        if (DEBUG) Log.v(TAG, "performEntryTransition");
496        final View mainView = getView();
497
498        mainView.setVisibility(View.INVISIBLE);
499
500        ArrayList<Animator> animators = new ArrayList<Animator>();
501        mGuidanceStylist.onActivityEnter(animators);
502        mActionsStylist.onActivityEnter(animators);
503
504        final Animator animator = createDummyAnimator(mainView, animators);
505
506        // We need to defer the animation until the first layout has occurred, as we don't yet
507        // know the final locations of views.
508        mainView.getViewTreeObserver().addOnGlobalLayoutListener(
509                new ViewTreeObserver.OnGlobalLayoutListener() {
510                    @Override
511                    public void onGlobalLayout() {
512                        mainView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
513                        if (!isAdded()) {
514                            // We have been detached before this could run,
515                            // so just bail
516                            return;
517                        }
518
519                        mainView.setVisibility(View.VISIBLE);
520                        animator.start();
521                    }
522                });
523    }
524
525    private Animator createDummyAnimator(final View v, ArrayList<Animator> animators) {
526        final AnimatorSet animatorSet = new AnimatorSet();
527        animatorSet.playTogether(animators);
528        return new UntargetableAnimatorSet(animatorSet);
529    }
530
531}
532