1/* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package androidx.wear.widget.drawer; 18 19import android.content.Context; 20import android.content.res.Resources; 21import android.content.res.TypedArray; 22import android.graphics.drawable.Drawable; 23import android.util.AttributeSet; 24import android.util.Log; 25import android.view.Gravity; 26import android.view.LayoutInflater; 27import android.view.Menu; 28import android.view.MenuInflater; 29import android.view.MenuItem; 30import android.view.MenuItem.OnMenuItemClickListener; 31import android.view.View; 32import android.view.ViewGroup; 33import android.view.accessibility.AccessibilityEvent; 34import android.view.accessibility.AccessibilityManager; 35import android.widget.ImageView; 36import android.widget.LinearLayout; 37import android.widget.TextView; 38 39import androidx.annotation.Nullable; 40import androidx.recyclerview.widget.LinearLayoutManager; 41import androidx.recyclerview.widget.RecyclerView; 42import androidx.wear.R; 43import androidx.wear.internal.widget.ResourcesUtil; 44import androidx.wear.widget.drawer.WearableActionDrawerMenu.WearableActionDrawerMenuItem; 45 46import java.util.Objects; 47 48/** 49 * Ease of use class for creating a Wearable action drawer. This can be used with {@link 50 * WearableDrawerLayout} to create a drawer for users to easily pull up contextual actions. These 51 * contextual actions may be specified by using a {@link Menu}, which may be populated by either: 52 * 53 * <ul> <li>Specifying the {@code app:actionMenu} attribute in the XML layout file. Example: 54 * <pre> 55 * <androidx.wear.widget.drawer.WearableActionDrawerView 56 * xmlns:app="http://schemas.android.com/apk/res-auto" 57 * android:layout_width=”match_parent” 58 * android:layout_height=”match_parent” 59 * app:actionMenu="@menu/action_drawer" /></pre> 60 * 61 * <li>Getting the menu with {@link #getMenu}, and then inflating it with {@link 62 * MenuInflater#inflate}. Example: 63 * <pre> 64 * Menu menu = actionDrawer.getMenu(); 65 * getMenuInflater().inflate(R.menu.action_drawer, menu);</pre> 66 * 67 * </ul> 68 * 69 * <p><b>The full {@link Menu} and {@link MenuItem} APIs are not implemented.</b> The following 70 * methods are guaranteed to work: 71 * 72 * <p>For {@link Menu}, the add methods, {@link Menu#clear}, {@link Menu#removeItem}, {@link 73 * Menu#findItem}, {@link Menu#size}, and {@link Menu#getItem} are implemented. 74 * 75 * <p>For {@link MenuItem}, setting and getting the title and icon, {@link MenuItem#getItemId}, and 76 * {@link MenuItem#setOnMenuItemClickListener} are implemented. 77 */ 78public class WearableActionDrawerView extends WearableDrawerView { 79 80 private static final String TAG = "WearableActionDrawer"; 81 82 private final RecyclerView mActionList; 83 private final int mTopPadding; 84 private final int mBottomPadding; 85 private final int mLeftPadding; 86 private final int mRightPadding; 87 private final int mFirstItemTopPadding; 88 private final int mLastItemBottomPadding; 89 private final int mIconRightMargin; 90 private final boolean mShowOverflowInPeek; 91 @Nullable private final ImageView mPeekActionIcon; 92 @Nullable private final ImageView mPeekExpandIcon; 93 private final RecyclerView.Adapter<RecyclerView.ViewHolder> mActionListAdapter; 94 private OnMenuItemClickListener mOnMenuItemClickListener; 95 private Menu mMenu; 96 @Nullable private CharSequence mTitle; 97 98 public WearableActionDrawerView(Context context) { 99 this(context, null); 100 } 101 102 public WearableActionDrawerView(Context context, AttributeSet attrs) { 103 this(context, attrs, 0); 104 } 105 106 public WearableActionDrawerView(Context context, AttributeSet attrs, int defStyleAttr) { 107 this(context, attrs, defStyleAttr, 0); 108 } 109 110 public WearableActionDrawerView( 111 Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 112 super(context, attrs, defStyleAttr, defStyleRes); 113 114 setLockedWhenClosed(true); 115 116 boolean showOverflowInPeek = false; 117 int menuRes = 0; 118 if (attrs != null) { 119 TypedArray typedArray = context.obtainStyledAttributes( 120 attrs, R.styleable.WearableActionDrawerView, defStyleAttr, 0 /* defStyleRes */); 121 122 try { 123 mTitle = typedArray.getString(R.styleable.WearableActionDrawerView_drawerTitle); 124 showOverflowInPeek = typedArray.getBoolean( 125 R.styleable.WearableActionDrawerView_showOverflowInPeek, false); 126 menuRes = typedArray 127 .getResourceId(R.styleable.WearableActionDrawerView_actionMenu, 0); 128 } finally { 129 typedArray.recycle(); 130 } 131 } 132 133 AccessibilityManager accessibilityManager = 134 (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); 135 mShowOverflowInPeek = showOverflowInPeek || accessibilityManager.isEnabled(); 136 137 if (!mShowOverflowInPeek) { 138 LayoutInflater layoutInflater = LayoutInflater.from(context); 139 View peekView = layoutInflater.inflate(R.layout.ws_action_drawer_peek_view, 140 getPeekContainer(), false /* attachToRoot */); 141 setPeekContent(peekView); 142 mPeekActionIcon = peekView.findViewById(R.id.ws_action_drawer_peek_action_icon); 143 mPeekExpandIcon = peekView.findViewById(R.id.ws_action_drawer_expand_icon); 144 } else { 145 mPeekActionIcon = null; 146 mPeekExpandIcon = null; 147 getPeekContainer().setContentDescription( 148 context.getString(R.string.ws_action_drawer_content_description)); 149 } 150 151 if (menuRes != 0) { 152 // This must occur after initializing mPeekActionIcon, otherwise updatePeekIcons will 153 // exit early. 154 MenuInflater inflater = new MenuInflater(context); 155 inflater.inflate(menuRes, getMenu()); 156 } 157 158 int screenWidthPx = ResourcesUtil.getScreenWidthPx(context); 159 int screenHeightPx = ResourcesUtil.getScreenHeightPx(context); 160 161 Resources res = getResources(); 162 mTopPadding = res.getDimensionPixelOffset(R.dimen.ws_action_drawer_item_top_padding); 163 mBottomPadding = res.getDimensionPixelOffset(R.dimen.ws_action_drawer_item_bottom_padding); 164 mLeftPadding = 165 ResourcesUtil.getFractionOfScreenPx( 166 context, screenWidthPx, R.fraction.ws_action_drawer_item_left_padding); 167 mRightPadding = 168 ResourcesUtil.getFractionOfScreenPx( 169 context, screenWidthPx, R.fraction.ws_action_drawer_item_right_padding); 170 171 mFirstItemTopPadding = 172 ResourcesUtil.getFractionOfScreenPx( 173 context, screenHeightPx, 174 R.fraction.ws_action_drawer_item_first_item_top_padding); 175 mLastItemBottomPadding = 176 ResourcesUtil.getFractionOfScreenPx( 177 context, screenHeightPx, 178 R.fraction.ws_action_drawer_item_last_item_bottom_padding); 179 180 mIconRightMargin = res 181 .getDimensionPixelOffset(R.dimen.ws_action_drawer_item_icon_right_margin); 182 183 mActionList = new RecyclerView(context); 184 mActionList.setLayoutManager(new LinearLayoutManager(context)); 185 mActionListAdapter = new ActionListAdapter(getMenu()); 186 mActionList.setAdapter(mActionListAdapter); 187 setDrawerContent(mActionList); 188 } 189 190 @Override 191 public void onDrawerOpened() { 192 if (mActionListAdapter.getItemCount() > 0) { 193 RecyclerView.ViewHolder holder = mActionList.findViewHolderForAdapterPosition(0); 194 if (holder != null && holder.itemView != null) { 195 holder.itemView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); 196 } 197 } 198 } 199 200 @Override 201 public boolean canScrollHorizontally(int direction) { 202 // Prevent the window from being swiped closed while it is open by saying that it can scroll 203 // horizontally. 204 return isOpened(); 205 } 206 207 @Override 208 public void onPeekContainerClicked(View v) { 209 if (mShowOverflowInPeek) { 210 super.onPeekContainerClicked(v); 211 } else { 212 onMenuItemClicked(0); 213 } 214 } 215 216 @Override 217 /* package */ int preferGravity() { 218 return Gravity.BOTTOM; 219 } 220 221 /** 222 * Set a {@link OnMenuItemClickListener} for this action drawer. 223 */ 224 public void setOnMenuItemClickListener(OnMenuItemClickListener listener) { 225 mOnMenuItemClickListener = listener; 226 } 227 228 /** 229 * Sets the title for this action drawer. If {@code title} is {@code null}, then the title will 230 * be removed. 231 */ 232 public void setTitle(@Nullable CharSequence title) { 233 if (Objects.equals(title, mTitle)) { 234 return; 235 } 236 237 CharSequence oldTitle = mTitle; 238 mTitle = title; 239 if (oldTitle == null) { 240 mActionListAdapter.notifyItemInserted(0); 241 } else if (title == null) { 242 mActionListAdapter.notifyItemRemoved(0); 243 } else { 244 mActionListAdapter.notifyItemChanged(0); 245 } 246 } 247 248 private boolean hasTitle() { 249 return mTitle != null; 250 } 251 252 private void onMenuItemClicked(int position) { 253 if (position >= 0 && position < getMenu().size()) { // Sanity check. 254 WearableActionDrawerMenuItem menuItem = 255 (WearableActionDrawerMenuItem) getMenu().getItem(position); 256 if (menuItem.invoke()) { 257 return; 258 } 259 260 if (mOnMenuItemClickListener != null) { 261 mOnMenuItemClickListener.onMenuItemClick(menuItem); 262 } 263 } 264 } 265 266 private void updatePeekIcons() { 267 if (mPeekActionIcon == null || mPeekExpandIcon == null) { 268 return; 269 } 270 271 Menu menu = getMenu(); 272 int numberOfActions = menu.size(); 273 274 // Only show drawer content (and allow it to be opened) when there's more than one action. 275 if (numberOfActions > 1) { 276 setDrawerContent(mActionList); 277 mPeekExpandIcon.setVisibility(VISIBLE); 278 } else { 279 setDrawerContent(null); 280 mPeekExpandIcon.setVisibility(GONE); 281 } 282 283 if (numberOfActions >= 1) { 284 Drawable firstActionDrawable = menu.getItem(0).getIcon(); 285 // Because the ImageView will tint the Drawable white, attempt to get a mutable copy of 286 // it. If a copy isn't made, the icon will be white in the expanded state, rendering it 287 // invisible. 288 if (firstActionDrawable != null) { 289 firstActionDrawable = firstActionDrawable.getConstantState().newDrawable().mutate(); 290 firstActionDrawable.clearColorFilter(); 291 } 292 293 mPeekActionIcon.setImageDrawable(firstActionDrawable); 294 mPeekActionIcon.setContentDescription(menu.getItem(0).getTitle()); 295 } 296 } 297 298 /** 299 * Returns the Menu object that this WearableActionDrawer represents. 300 * 301 * <p>Applications should use this method to obtain the WearableActionDrawers's Menu object and 302 * inflate or add content to it as necessary. 303 * 304 * @return the Menu presented by this view 305 */ 306 public Menu getMenu() { 307 if (mMenu == null) { 308 mMenu = new WearableActionDrawerMenu( 309 getContext(), 310 new WearableActionDrawerMenu.WearableActionDrawerMenuListener() { 311 @Override 312 public void menuItemChanged(int position) { 313 if (mActionListAdapter != null) { 314 int listPosition = hasTitle() ? position + 1 : position; 315 mActionListAdapter.notifyItemChanged(listPosition); 316 } 317 if (position == 0) { 318 updatePeekIcons(); 319 } 320 } 321 322 @Override 323 public void menuItemAdded(int position) { 324 if (mActionListAdapter != null) { 325 int listPosition = hasTitle() ? position + 1 : position; 326 mActionListAdapter.notifyItemInserted(listPosition); 327 } 328 // Handle transitioning from 0->1 items (set peek icon) and 329 // 1->2 (switch to ellipsis.) 330 if (position <= 1) { 331 updatePeekIcons(); 332 } 333 } 334 335 @Override 336 public void menuItemRemoved(int position) { 337 if (mActionListAdapter != null) { 338 int listPosition = hasTitle() ? position + 1 : position; 339 mActionListAdapter.notifyItemRemoved(listPosition); 340 } 341 // Handle transitioning from 2->1 items (remove ellipsis), and 342 // also the removal of item 1, which could cause the peek icon 343 // to change. 344 if (position <= 1) { 345 updatePeekIcons(); 346 } 347 } 348 349 @Override 350 public void menuChanged() { 351 if (mActionListAdapter != null) { 352 mActionListAdapter.notifyDataSetChanged(); 353 } 354 updatePeekIcons(); 355 } 356 }); 357 } 358 359 return mMenu; 360 } 361 362 private static final class TitleViewHolder extends RecyclerView.ViewHolder { 363 364 public final View view; 365 public final TextView textView; 366 367 TitleViewHolder(View view) { 368 super(view); 369 this.view = view; 370 textView = (TextView) view.findViewById(R.id.ws_action_drawer_title); 371 } 372 } 373 374 private final class ActionListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { 375 376 public static final int TYPE_ACTION = 0; 377 public static final int TYPE_TITLE = 1; 378 private final Menu mActionMenu; 379 private final View.OnClickListener mItemClickListener = 380 new View.OnClickListener() { 381 @Override 382 public void onClick(View v) { 383 int childPos = 384 mActionList.getChildAdapterPosition(v) - (hasTitle() ? 1 : 0); 385 if (childPos == RecyclerView.NO_POSITION) { 386 Log.w(TAG, "invalid child position"); 387 return; 388 } 389 onMenuItemClicked(childPos); 390 } 391 }; 392 393 ActionListAdapter(Menu menu) { 394 mActionMenu = getMenu(); 395 } 396 397 @Override 398 public int getItemCount() { 399 return mActionMenu.size() + (hasTitle() ? 1 : 0); 400 } 401 402 @Override 403 public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { 404 int titleAwarePosition = hasTitle() ? position - 1 : position; 405 if (viewHolder instanceof ActionItemViewHolder) { 406 ActionItemViewHolder holder = (ActionItemViewHolder) viewHolder; 407 holder.view.setPadding( 408 mLeftPadding, 409 position == 0 ? mFirstItemTopPadding : mTopPadding, 410 mRightPadding, 411 position == getItemCount() - 1 ? mLastItemBottomPadding : mBottomPadding); 412 413 Drawable icon = mActionMenu.getItem(titleAwarePosition).getIcon(); 414 if (icon != null) { 415 icon = icon.getConstantState().newDrawable().mutate(); 416 } 417 CharSequence title = mActionMenu.getItem(titleAwarePosition).getTitle(); 418 holder.textView.setText(title); 419 holder.textView.setContentDescription(title); 420 holder.iconView.setImageDrawable(icon); 421 } else if (viewHolder instanceof TitleViewHolder) { 422 TitleViewHolder holder = (TitleViewHolder) viewHolder; 423 holder.textView.setPadding(0, mFirstItemTopPadding, 0, mBottomPadding); 424 holder.textView.setText(mTitle); 425 } 426 } 427 428 @Override 429 public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 430 switch (viewType) { 431 case TYPE_TITLE: 432 View titleView = 433 LayoutInflater.from(parent.getContext()) 434 .inflate(R.layout.ws_action_drawer_title_view, parent, false); 435 return new TitleViewHolder(titleView); 436 437 case TYPE_ACTION: 438 default: 439 View actionView = 440 LayoutInflater.from(parent.getContext()) 441 .inflate(R.layout.ws_action_drawer_item_view, parent, false); 442 actionView.setOnClickListener(mItemClickListener); 443 return new ActionItemViewHolder(actionView); 444 } 445 } 446 447 @Override 448 public int getItemViewType(int position) { 449 return hasTitle() && position == 0 ? TYPE_TITLE : TYPE_ACTION; 450 } 451 } 452 453 private final class ActionItemViewHolder extends RecyclerView.ViewHolder { 454 455 public final View view; 456 public final ImageView iconView; 457 public final TextView textView; 458 459 ActionItemViewHolder(View view) { 460 super(view); 461 this.view = view; 462 iconView = (ImageView) view.findViewById(R.id.ws_action_drawer_item_icon); 463 ((LinearLayout.LayoutParams) iconView.getLayoutParams()).setMarginEnd(mIconRightMargin); 464 textView = (TextView) view.findViewById(R.id.ws_action_drawer_item_text); 465 } 466 } 467} 468