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 * &lt;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" /&gt;</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