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