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