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  */
14 package androidx.leanback.widget;
15 
16 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
17 
18 import android.util.Log;
19 import android.view.KeyEvent;
20 import android.view.View;
21 import android.view.ViewGroup;
22 import android.view.ViewParent;
23 import android.view.inputmethod.EditorInfo;
24 import android.widget.EditText;
25 import android.widget.TextView;
26 import android.widget.TextView.OnEditorActionListener;
27 
28 import androidx.annotation.Nullable;
29 import androidx.annotation.RestrictTo;
30 import androidx.recyclerview.widget.DiffUtil;
31 import androidx.recyclerview.widget.RecyclerView;
32 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
33 
34 import java.util.ArrayList;
35 import java.util.List;
36 
37 /**
38  * GuidedActionAdapter instantiates views for guided actions, and manages their interactions.
39  * Presentation (view creation and state animation) is delegated to a {@link
40  * GuidedActionsStylist}, while clients are notified of interactions via
41  * {@link GuidedActionAdapter.ClickListener} and {@link GuidedActionAdapter.FocusListener}.
42  * @hide
43  */
44 @RestrictTo(LIBRARY_GROUP)
45 public class GuidedActionAdapter extends RecyclerView.Adapter {
46     static final String TAG = "GuidedActionAdapter";
47     static final boolean DEBUG = false;
48 
49     static final String TAG_EDIT = "EditableAction";
50     static final boolean DEBUG_EDIT = false;
51 
52     /**
53      * Object listening for click events within a {@link GuidedActionAdapter}.
54      */
55     public interface ClickListener {
56 
57         /**
58          * Called when the user clicks on an action.
59          */
onGuidedActionClicked(GuidedAction action)60         void onGuidedActionClicked(GuidedAction action);
61 
62     }
63 
64     /**
65      * Object listening for focus events within a {@link GuidedActionAdapter}.
66      */
67     public interface FocusListener {
68 
69         /**
70          * Called when the user focuses on an action.
71          */
onGuidedActionFocused(GuidedAction action)72         void onGuidedActionFocused(GuidedAction action);
73     }
74 
75     /**
76      * Object listening for edit events within a {@link GuidedActionAdapter}.
77      */
78     public interface EditListener {
79 
80         /**
81          * Called when the user exits edit mode on an action.
82          */
onGuidedActionEditCanceled(GuidedAction action)83         void onGuidedActionEditCanceled(GuidedAction action);
84 
85         /**
86          * Called when the user exits edit mode on an action and process confirm button in IME.
87          */
onGuidedActionEditedAndProceed(GuidedAction action)88         long onGuidedActionEditedAndProceed(GuidedAction action);
89 
90         /**
91          * Called when Ime Open
92          */
onImeOpen()93         void onImeOpen();
94 
95         /**
96          * Called when Ime Close
97          */
onImeClose()98         void onImeClose();
99     }
100 
101     private final boolean mIsSubAdapter;
102     private final ActionOnKeyListener mActionOnKeyListener;
103     private final ActionOnFocusListener mActionOnFocusListener;
104     private final ActionEditListener mActionEditListener;
105     private final ActionAutofillListener mActionAutofillListener;
106     private final List<GuidedAction> mActions;
107     private ClickListener mClickListener;
108     final GuidedActionsStylist mStylist;
109     GuidedActionAdapterGroup mGroup;
110     DiffCallback<GuidedAction> mDiffCallback;
111 
112     private final View.OnClickListener mOnClickListener = new View.OnClickListener() {
113         @Override
114         public void onClick(View v) {
115             if (v != null && v.getWindowToken() != null && getRecyclerView() != null) {
116                 GuidedActionsStylist.ViewHolder avh = (GuidedActionsStylist.ViewHolder)
117                         getRecyclerView().getChildViewHolder(v);
118                 GuidedAction action = avh.getAction();
119                 if (action.hasTextEditable()) {
120                     if (DEBUG_EDIT) Log.v(TAG_EDIT, "openIme by click");
121                     mGroup.openIme(GuidedActionAdapter.this, avh);
122                 } else if (action.hasEditableActivatorView()) {
123                     if (DEBUG_EDIT) Log.v(TAG_EDIT, "toggle editing mode by click");
124                     performOnActionClick(avh);
125                 } else {
126                     handleCheckedActions(avh);
127                     if (action.isEnabled() && !action.infoOnly()) {
128                         performOnActionClick(avh);
129                     }
130                 }
131             }
132         }
133     };
134 
135     /**
136      * Constructs a GuidedActionAdapter with the given list of guided actions, the given click and
137      * focus listeners, and the given presenter.
138      * @param actions The list of guided actions this adapter will manage.
139      * @param focusListener The focus listener for items in this adapter.
140      * @param presenter The presenter that will manage the display of items in this adapter.
141      */
GuidedActionAdapter(List<GuidedAction> actions, ClickListener clickListener, FocusListener focusListener, GuidedActionsStylist presenter, boolean isSubAdapter)142     public GuidedActionAdapter(List<GuidedAction> actions, ClickListener clickListener,
143             FocusListener focusListener, GuidedActionsStylist presenter, boolean isSubAdapter) {
144         super();
145         mActions = actions == null ? new ArrayList<GuidedAction>() :
146                 new ArrayList<GuidedAction>(actions);
147         mClickListener = clickListener;
148         mStylist = presenter;
149         mActionOnKeyListener = new ActionOnKeyListener();
150         mActionOnFocusListener = new ActionOnFocusListener(focusListener);
151         mActionEditListener = new ActionEditListener();
152         mActionAutofillListener = new ActionAutofillListener();
153         mIsSubAdapter = isSubAdapter;
154         if (!isSubAdapter) {
155             mDiffCallback = GuidedActionDiffCallback.getInstance();
156         }
157     }
158 
159     /**
160      * Change DiffCallback used in {@link #setActions(List)}. Set to null for firing a
161      * general {@link #notifyDataSetChanged()}.
162      *
163      * @param diffCallback
164      */
setDiffCallback(DiffCallback<GuidedAction> diffCallback)165     public void setDiffCallback(DiffCallback<GuidedAction> diffCallback) {
166         mDiffCallback = diffCallback;
167     }
168 
169     /**
170      * Sets the list of actions managed by this adapter. Use {@link #setDiffCallback(DiffCallback)}
171      * to change DiffCallback.
172      * @param actions The list of actions to be managed.
173      */
setActions(final List<GuidedAction> actions)174     public void setActions(final List<GuidedAction> actions) {
175         if (!mIsSubAdapter) {
176             mStylist.collapseAction(false);
177         }
178         mActionOnFocusListener.unFocus();
179         if (mDiffCallback != null) {
180             // temporary variable used for DiffCallback
181             final List<GuidedAction> oldActions = new ArrayList();
182             oldActions.addAll(mActions);
183 
184             // update items.
185             mActions.clear();
186             mActions.addAll(actions);
187 
188             DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffUtil.Callback() {
189                 @Override
190                 public int getOldListSize() {
191                     return oldActions.size();
192                 }
193 
194                 @Override
195                 public int getNewListSize() {
196                     return mActions.size();
197                 }
198 
199                 @Override
200                 public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
201                     return mDiffCallback.areItemsTheSame(oldActions.get(oldItemPosition),
202                             mActions.get(newItemPosition));
203                 }
204 
205                 @Override
206                 public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
207                     return mDiffCallback.areContentsTheSame(oldActions.get(oldItemPosition),
208                             mActions.get(newItemPosition));
209                 }
210 
211                 @Nullable
212                 @Override
213                 public Object getChangePayload(int oldItemPosition, int newItemPosition) {
214                     return mDiffCallback.getChangePayload(oldActions.get(oldItemPosition),
215                             mActions.get(newItemPosition));
216                 }
217             });
218 
219             // dispatch diff result
220             diffResult.dispatchUpdatesTo(this);
221         } else {
222             mActions.clear();
223             mActions.addAll(actions);
224             notifyDataSetChanged();
225         }
226     }
227 
228     /**
229      * Returns the count of actions managed by this adapter.
230      * @return The count of actions managed by this adapter.
231      */
getCount()232     public int getCount() {
233         return mActions.size();
234     }
235 
236     /**
237      * Returns the GuidedAction at the given position in the managed list.
238      * @param position The position of the desired GuidedAction.
239      * @return The GuidedAction at the given position.
240      */
getItem(int position)241     public GuidedAction getItem(int position) {
242         return mActions.get(position);
243     }
244 
245     /**
246      * Return index of action in array
247      * @param action Action to search index.
248      * @return Index of Action in array.
249      */
indexOf(GuidedAction action)250     public int indexOf(GuidedAction action) {
251         return mActions.indexOf(action);
252     }
253 
254     /**
255      * @return GuidedActionsStylist used to build the actions list UI.
256      */
getGuidedActionsStylist()257     public GuidedActionsStylist getGuidedActionsStylist() {
258         return mStylist;
259     }
260 
261     /**
262      * Sets the click listener for items managed by this adapter.
263      * @param clickListener The click listener for this adapter.
264      */
setClickListener(ClickListener clickListener)265     public void setClickListener(ClickListener clickListener) {
266         mClickListener = clickListener;
267     }
268 
269     /**
270      * Sets the focus listener for items managed by this adapter.
271      * @param focusListener The focus listener for this adapter.
272      */
setFocusListener(FocusListener focusListener)273     public void setFocusListener(FocusListener focusListener) {
274         mActionOnFocusListener.setFocusListener(focusListener);
275     }
276 
277     /**
278      * Used for serialization only.
279      * @hide
280      */
281     @RestrictTo(LIBRARY_GROUP)
getActions()282     public List<GuidedAction> getActions() {
283         return new ArrayList<GuidedAction>(mActions);
284     }
285 
286     /**
287      * {@inheritDoc}
288      */
289     @Override
getItemViewType(int position)290     public int getItemViewType(int position) {
291         return mStylist.getItemViewType(mActions.get(position));
292     }
293 
getRecyclerView()294     RecyclerView getRecyclerView() {
295         return mIsSubAdapter ? mStylist.getSubActionsGridView() : mStylist.getActionsGridView();
296     }
297 
298     /**
299      * {@inheritDoc}
300      */
301     @Override
onCreateViewHolder(ViewGroup parent, int viewType)302     public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
303         GuidedActionsStylist.ViewHolder vh = mStylist.onCreateViewHolder(parent, viewType);
304         View v = vh.itemView;
305         v.setOnKeyListener(mActionOnKeyListener);
306         v.setOnClickListener(mOnClickListener);
307         v.setOnFocusChangeListener(mActionOnFocusListener);
308 
309         setupListeners(vh.getEditableTitleView());
310         setupListeners(vh.getEditableDescriptionView());
311 
312         return vh;
313     }
314 
setupListeners(EditText edit)315     private void setupListeners(EditText edit) {
316         if (edit != null) {
317             edit.setPrivateImeOptions("EscapeNorth=1;");
318             edit.setOnEditorActionListener(mActionEditListener);
319             if (edit instanceof ImeKeyMonitor) {
320                 ImeKeyMonitor monitor = (ImeKeyMonitor)edit;
321                 monitor.setImeKeyListener(mActionEditListener);
322             }
323             if (edit instanceof GuidedActionAutofillSupport) {
324                 ((GuidedActionAutofillSupport) edit).setOnAutofillListener(mActionAutofillListener);
325             }
326         }
327     }
328 
329     /**
330      * {@inheritDoc}
331      */
332     @Override
onBindViewHolder(ViewHolder holder, int position)333     public void onBindViewHolder(ViewHolder holder, int position) {
334         if (position >= mActions.size()) {
335             return;
336         }
337         final GuidedActionsStylist.ViewHolder avh = (GuidedActionsStylist.ViewHolder)holder;
338         GuidedAction action = mActions.get(position);
339         mStylist.onBindViewHolder(avh, action);
340     }
341 
342     /**
343      * {@inheritDoc}
344      */
345     @Override
getItemCount()346     public int getItemCount() {
347         return mActions.size();
348     }
349 
350     private class ActionOnFocusListener implements View.OnFocusChangeListener {
351 
352         private FocusListener mFocusListener;
353         private View mSelectedView;
354 
ActionOnFocusListener(FocusListener focusListener)355         ActionOnFocusListener(FocusListener focusListener) {
356             mFocusListener = focusListener;
357         }
358 
setFocusListener(FocusListener focusListener)359         public void setFocusListener(FocusListener focusListener) {
360             mFocusListener = focusListener;
361         }
362 
unFocus()363         public void unFocus() {
364             if (mSelectedView != null && getRecyclerView() != null) {
365                 ViewHolder vh = getRecyclerView().getChildViewHolder(mSelectedView);
366                 if (vh != null) {
367                     GuidedActionsStylist.ViewHolder avh = (GuidedActionsStylist.ViewHolder)vh;
368                     mStylist.onAnimateItemFocused(avh, false);
369                 } else {
370                     Log.w(TAG, "RecyclerView returned null view holder",
371                             new Throwable());
372                 }
373             }
374         }
375 
376         @Override
onFocusChange(View v, boolean hasFocus)377         public void onFocusChange(View v, boolean hasFocus) {
378             if (getRecyclerView() == null) {
379                 return;
380             }
381             GuidedActionsStylist.ViewHolder avh = (GuidedActionsStylist.ViewHolder)
382                     getRecyclerView().getChildViewHolder(v);
383             if (hasFocus) {
384                 mSelectedView = v;
385                 if (mFocusListener != null) {
386                     // We still call onGuidedActionFocused so that listeners can clear
387                     // state if they want.
388                     mFocusListener.onGuidedActionFocused(avh.getAction());
389                 }
390             } else {
391                 if (mSelectedView == v) {
392                     mStylist.onAnimateItemPressedCancelled(avh);
393                     mSelectedView = null;
394                 }
395             }
396             mStylist.onAnimateItemFocused(avh, hasFocus);
397         }
398     }
399 
findSubChildViewHolder(View v)400     public GuidedActionsStylist.ViewHolder findSubChildViewHolder(View v) {
401         // Needed because RecyclerView.getChildViewHolder does not traverse the hierarchy
402         if (getRecyclerView() == null) {
403             return null;
404         }
405         GuidedActionsStylist.ViewHolder result = null;
406         ViewParent parent = v.getParent();
407         while (parent != getRecyclerView() && parent != null && v != null) {
408             v = (View)parent;
409             parent = parent.getParent();
410         }
411         if (parent != null && v != null) {
412             result = (GuidedActionsStylist.ViewHolder)getRecyclerView().getChildViewHolder(v);
413         }
414         return result;
415     }
416 
handleCheckedActions(GuidedActionsStylist.ViewHolder avh)417     public void handleCheckedActions(GuidedActionsStylist.ViewHolder avh) {
418         GuidedAction action = avh.getAction();
419         int actionCheckSetId = action.getCheckSetId();
420         if (getRecyclerView() != null && actionCheckSetId != GuidedAction.NO_CHECK_SET) {
421             // Find any actions that are checked and are in the same group
422             // as the selected action. Fade their checkmarks out.
423             if (actionCheckSetId != GuidedAction.CHECKBOX_CHECK_SET_ID) {
424                 for (int i = 0, size = mActions.size(); i < size; i++) {
425                     GuidedAction a = mActions.get(i);
426                     if (a != action && a.getCheckSetId() == actionCheckSetId && a.isChecked()) {
427                         a.setChecked(false);
428                         GuidedActionsStylist.ViewHolder vh = (GuidedActionsStylist.ViewHolder)
429                                 getRecyclerView().findViewHolderForPosition(i);
430                         if (vh != null) {
431                             mStylist.onAnimateItemChecked(vh, false);
432                         }
433                     }
434                 }
435             }
436 
437             // If we we'ren't already checked, fade our checkmark in.
438             if (!action.isChecked()) {
439                 action.setChecked(true);
440                 mStylist.onAnimateItemChecked(avh, true);
441             } else {
442                 if (actionCheckSetId == GuidedAction.CHECKBOX_CHECK_SET_ID) {
443                     action.setChecked(false);
444                     mStylist.onAnimateItemChecked(avh, false);
445                 }
446             }
447         }
448     }
449 
performOnActionClick(GuidedActionsStylist.ViewHolder avh)450     public void performOnActionClick(GuidedActionsStylist.ViewHolder avh) {
451         if (mClickListener != null) {
452             mClickListener.onGuidedActionClicked(avh.getAction());
453         }
454     }
455 
456     private class ActionOnKeyListener implements View.OnKeyListener {
457 
458         private boolean mKeyPressed = false;
459 
ActionOnKeyListener()460         ActionOnKeyListener() {
461         }
462 
463         /**
464          * Now only handles KEYCODE_ENTER and KEYCODE_NUMPAD_ENTER key event.
465          */
466         @Override
onKey(View v, int keyCode, KeyEvent event)467         public boolean onKey(View v, int keyCode, KeyEvent event) {
468             if (v == null || event == null || getRecyclerView() == null) {
469                 return false;
470             }
471             boolean handled = false;
472             switch (keyCode) {
473                 case KeyEvent.KEYCODE_DPAD_CENTER:
474                 case KeyEvent.KEYCODE_NUMPAD_ENTER:
475                 case KeyEvent.KEYCODE_BUTTON_X:
476                 case KeyEvent.KEYCODE_BUTTON_Y:
477                 case KeyEvent.KEYCODE_ENTER:
478 
479                     GuidedActionsStylist.ViewHolder avh = (GuidedActionsStylist.ViewHolder)
480                             getRecyclerView().getChildViewHolder(v);
481                     GuidedAction action = avh.getAction();
482 
483                     if (!action.isEnabled() || action.infoOnly()) {
484                         if (event.getAction() == KeyEvent.ACTION_DOWN) {
485                             // TODO: requires API 19
486                             //playSound(v, AudioManager.FX_KEYPRESS_INVALID);
487                         }
488                         return true;
489                     }
490 
491                     switch (event.getAction()) {
492                         case KeyEvent.ACTION_DOWN:
493                             if (DEBUG) {
494                                 Log.d(TAG, "Enter Key down");
495                             }
496                             if (!mKeyPressed) {
497                                 mKeyPressed = true;
498                                 mStylist.onAnimateItemPressed(avh, mKeyPressed);
499                             }
500                             break;
501                         case KeyEvent.ACTION_UP:
502                             if (DEBUG) {
503                                 Log.d(TAG, "Enter Key up");
504                             }
505                             // Sometimes we are losing ACTION_DOWN for the first ENTER after pressed
506                             // Escape in IME.
507                             if (mKeyPressed) {
508                                 mKeyPressed = false;
509                                 mStylist.onAnimateItemPressed(avh, mKeyPressed);
510                             }
511                             break;
512                         default:
513                             break;
514                     }
515                     break;
516                 default:
517                     break;
518             }
519             return handled;
520         }
521 
522     }
523 
524     private class ActionEditListener implements OnEditorActionListener,
525             ImeKeyMonitor.ImeKeyListener {
526 
ActionEditListener()527         ActionEditListener() {
528         }
529 
530         @Override
onEditorAction(TextView v, int actionId, KeyEvent event)531         public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
532             if (DEBUG_EDIT) Log.v(TAG_EDIT, "IME action: " + actionId);
533             boolean handled = false;
534             if (actionId == EditorInfo.IME_ACTION_NEXT
535                     || actionId == EditorInfo.IME_ACTION_DONE) {
536                 mGroup.fillAndGoNext(GuidedActionAdapter.this, v);
537                 handled = true;
538             } else if (actionId == EditorInfo.IME_ACTION_NONE) {
539                 if (DEBUG_EDIT) Log.v(TAG_EDIT, "closeIme escape north");
540                 // Escape north handling: stay on current item, but close editor
541                 handled = true;
542                 mGroup.fillAndStay(GuidedActionAdapter.this, v);
543             }
544             return handled;
545         }
546 
547         @Override
onKeyPreIme(EditText editText, int keyCode, KeyEvent event)548         public boolean onKeyPreIme(EditText editText, int keyCode, KeyEvent event) {
549             if (DEBUG_EDIT) Log.v(TAG_EDIT, "IME key: " + keyCode);
550             if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) {
551                 mGroup.fillAndStay(GuidedActionAdapter.this, editText);
552                 return true;
553             } else if (keyCode == KeyEvent.KEYCODE_ENTER
554                     && event.getAction() == KeyEvent.ACTION_UP) {
555                 mGroup.fillAndGoNext(GuidedActionAdapter.this, editText);
556                 return true;
557             }
558             return false;
559         }
560     }
561 
562     private class ActionAutofillListener implements GuidedActionAutofillSupport.OnAutofillListener {
563         @Override
onAutofill(View view)564         public void onAutofill(View view) {
565             mGroup.fillAndGoNext(GuidedActionAdapter.this, (EditText) view);
566         }
567     }
568 }
569