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 
17 package com.android.car.apps.common;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.drawable.Drawable;
22 import android.transition.ChangeBounds;
23 import android.transition.Fade;
24 import android.transition.TransitionManager;
25 import android.transition.TransitionSet;
26 import android.util.AttributeSet;
27 import android.util.Log;
28 import android.util.SparseArray;
29 import android.view.LayoutInflater;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.widget.FrameLayout;
33 import android.widget.ImageButton;
34 import android.widget.LinearLayout;
35 import android.widget.RelativeLayout;
36 import android.widget.Space;
37 
38 import androidx.annotation.NonNull;
39 import androidx.annotation.Nullable;
40 import androidx.annotation.VisibleForTesting;
41 import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
42 
43 import com.android.internal.util.Preconditions;
44 
45 import java.util.Locale;
46 
47 
48 /**
49  * An actions panel with three distinctive zones:
50  * <ul>
51  * <li>Main control: located in the bottom center it shows a highlighted icon and a circular
52  * progress bar.
53  * <li>Secondary controls: these are displayed at the left and at the right of the main control.
54  * <li>Overflow controls: these are displayed at the left and at the right of the secondary controls
55  * (if the space allows) and on the additional space if the panel is expanded.
56  * </ul>
57  */
58 public class ControlBar extends RelativeLayout implements ExpandableControlBar {
59     private static final String TAG = "ControlBar";
60 
61     // Rows container
62     private ViewGroup mRowsContainer;
63     // All slots in this action bar where 0 is the bottom-start corner of the matrix, and
64     // mNumColumns * nNumRows - 1 is the top-end corner
65     private FrameLayout[] mSlots;
66     /**
67      * Reference to the first slot we create. Used to properly inflate buttons without loosing
68      * their layout params.
69      */
70     private FrameLayout mFirstCreatedSlot;
71     /** Views to set in particular {@link SlotPosition}s */
72     private final SparseArray<View> mFixedViews = new SparseArray<>();
73     // View to be used for the expand/collapse action
74     private @Nullable View mExpandCollapseView;
75     // Default expand/collapse view to use one is not provided.
76     private View mDefaultExpandCollapseView;
77     // Number of rows in actual use. This is the number of extra rows that will be displayed when
78     // the action bar is expanded
79     private int mNumExtraRowsInUse;
80     // Whether the action bar is expanded or not.
81     private boolean mIsExpanded;
82     // Views to accomodate in the slots.
83     private @Nullable View[] mViews;
84     // Number of columns of slots to use.
85     private int mNumColumns;
86     // Maximum number of rows to use (at least one!).
87     private int mNumRows;
88     // Whether the expand button should be visible or not
89     private boolean mExpandEnabled;
90     // Callback for the expand/collapse button
91     private ExpandCollapseCallback mExpandCollapseCallback;
92 
93     // Default number of columns, if unspecified
94     private static final int DEFAULT_COLUMNS = 3;
95     // Weight for the spacers used between buttons
96     private static final float SPACERS_WEIGHT = 1f;
97 
ControlBar(Context context)98     public ControlBar(Context context) {
99         super(context);
100         init(context, null, 0, 0);
101     }
102 
ControlBar(Context context, AttributeSet attrs)103     public ControlBar(Context context, AttributeSet attrs) {
104         super(context, attrs);
105         init(context, attrs, 0, 0);
106     }
107 
ControlBar(Context context, AttributeSet attrs, int defStyleAttrs)108     public ControlBar(Context context, AttributeSet attrs, int defStyleAttrs) {
109         super(context, attrs, defStyleAttrs);
110         init(context, attrs, defStyleAttrs, 0);
111     }
112 
ControlBar(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)113     public ControlBar(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
114         super(context, attrs, defStyleAttrs, defStyleRes);
115         init(context, attrs, defStyleAttrs, defStyleRes);
116     }
117 
init(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)118     private void init(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
119         inflate(context, R.layout.control_bar, this);
120 
121         TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ControlBar,
122                 defStyleAttrs, defStyleRes);
123         mNumColumns = ta.getInteger(R.styleable.ControlBar_columns, DEFAULT_COLUMNS);
124         mExpandEnabled = ta.getBoolean(R.styleable.ControlBar_enableOverflow, true);
125         ta.recycle();
126 
127         mRowsContainer = findViewById(R.id.rows_container);
128         mNumRows = mRowsContainer.getChildCount();
129         Preconditions.checkState(mNumRows > 0, "Must have at least 1 row");
130 
131         mSlots = new FrameLayout[mNumColumns * mNumRows];
132 
133         LayoutInflater inflater = LayoutInflater.from(context);
134         final boolean attachToRoot = false;
135 
136         for (int i = 0; i < mNumRows; i++) {
137             // Slots are reserved in reverse order (first slots are in the bottom row)
138             ViewGroup row = (ViewGroup) mRowsContainer.getChildAt(mNumRows - i - 1);
139             // Inflate necessary number of columns
140             for (int j = 0; j < mNumColumns; j++) {
141                 int pos = i * mNumColumns + j;
142                 mSlots[pos] = (FrameLayout) inflater.inflate(R.layout.control_bar_slot, row,
143                         attachToRoot);
144                 if (mFirstCreatedSlot == null) {
145                     mFirstCreatedSlot = mSlots[pos];
146                 }
147                 if (j > 0) {
148                     Space space = new Space(context);
149                     row.addView(space);
150                     space.setLayoutParams(new LinearLayout.LayoutParams(0,
151                             ViewGroup.LayoutParams.MATCH_PARENT, SPACERS_WEIGHT));
152                 }
153                 row.addView(mSlots[pos]);
154             }
155         }
156 
157         mDefaultExpandCollapseView = createIconButton(
158                 context.getDrawable(R.drawable.ic_overflow_button));
159         mDefaultExpandCollapseView.setContentDescription(context.getString(
160                 R.string.control_bar_expand_collapse_button));
161         mDefaultExpandCollapseView.setOnClickListener(v -> onExpandCollapse());
162     }
163 
getSlotIndex(@lotPosition int slotPosition)164     private int getSlotIndex(@SlotPosition int slotPosition) {
165         return CarControlBar.getSlotIndex(slotPosition, mNumColumns);
166     }
167 
168     @Override
setView(@ullable View view, @SlotPosition int slotPosition)169     public void setView(@Nullable View view, @SlotPosition int slotPosition) {
170         if (view != null) {
171             mFixedViews.put(slotPosition, view);
172         } else {
173             mFixedViews.remove(slotPosition);
174         }
175         updateViewsLayout();
176     }
177 
178     /**
179      * Sets the view to use for the expand/collapse action. If not provided, a default
180      * {@link ImageButton} will be used. The provided {@link View} should be able be able to display
181      * changes in the "activated" state appropriately.
182      *
183      * @param view {@link View} to use for the expand/collapse action.
184      */
setExpandCollapseView(@onNull View view)185     public void setExpandCollapseView(@NonNull View view) {
186         mExpandCollapseView = view;
187         mExpandCollapseView.setOnClickListener(v -> onExpandCollapse());
188         updateViewsLayout();
189     }
190 
getExpandCollapseView()191     private View getExpandCollapseView() {
192         return mExpandCollapseView != null ? mExpandCollapseView : mDefaultExpandCollapseView;
193     }
194 
195     @Override
createIconButton(Drawable icon)196     public ImageButton createIconButton(Drawable icon) {
197         return createIconButton(icon, R.layout.control_bar_button);
198     }
199 
200     @Override
createIconButton(Drawable icon, int viewId)201     public ImageButton createIconButton(Drawable icon, int viewId) {
202         LayoutInflater inflater = LayoutInflater.from(mFirstCreatedSlot.getContext());
203         final boolean attachToRoot = false;
204         ImageButton button = (ImageButton) inflater.inflate(viewId, mFirstCreatedSlot,
205                 attachToRoot);
206         button.setImageDrawable(icon);
207         return button;
208     }
209 
210     @Override
registerExpandCollapseCallback(@ullable ExpandCollapseCallback callback)211     public void registerExpandCollapseCallback(@Nullable ExpandCollapseCallback callback) {
212         mExpandCollapseCallback = callback;
213     }
214 
215     @Override
close()216     public void close() {
217         if (mIsExpanded) {
218             onExpandCollapse();
219         }
220     }
221 
222     @Override
setViews(@ullable View[] views)223     public void setViews(@Nullable View[] views) {
224         mViews = views;
225         updateViewsLayout();
226     }
227 
updateViewsLayout()228     private void updateViewsLayout() {
229         // Prepare an array of positions taken
230         int totalSlots = mSlots.length;
231         View[] slotViews = new View[totalSlots];
232 
233         // Take all known positions
234         for (int i = 0; i < mFixedViews.size(); i++) {
235             int index = getSlotIndex(mFixedViews.keyAt(i));
236             if (index >= 0 && index < slotViews.length) {
237                 slotViews[index] = mFixedViews.valueAt(i);
238             }
239         }
240 
241         // Set all views using both the fixed and flexible positions
242         int expandCollapseIndex = getSlotIndex(SLOT_EXPAND_COLLAPSE);
243         int lastUsedIndex = 0;
244         int viewsIndex = 0;
245         for (int i = 0; i < totalSlots; i++) {
246             View viewToUse = null;
247 
248             if (slotViews[i] != null) {
249                 // If there is a view assigned for this slot, use it.
250                 viewToUse = slotViews[i];
251             } else if (mExpandEnabled && i == expandCollapseIndex && mViews != null
252                     && viewsIndex < mViews.length - 1) {
253                 // If this is the expand/collapse slot, use the corresponding view
254                 viewToUse = getExpandCollapseView();
255                 Log.d(TAG, "" + this + "Setting expand control");
256             } else if (mViews != null && viewsIndex < mViews.length) {
257                 // Otherwise, if the slot is not reserved, and if we still have views to assign,
258                 // take one and assign it to this slot.
259                 viewToUse = mViews[viewsIndex];
260                 viewsIndex++;
261             }
262             setView(viewToUse, mSlots[i]);
263             if (viewToUse != null) {
264                 lastUsedIndex = i;
265             }
266         }
267 
268         mNumExtraRowsInUse = lastUsedIndex / mNumColumns;
269         final int lastIndex = lastUsedIndex;
270 
271         if (mNumRows > 1) {
272             // Align expanded control bar rows
273             mRowsContainer.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
274                 for (int i  = 1; i < mNumRows; i++) {
275                     // mRowsContainer's children are in reverse order (last row is at index 0)
276                     int rowIndex = mNumRows - 1 - i;
277                     if (lastIndex < (i + 1) * mNumColumns) {
278                         // Align the last row's center with the first row by translating the last
279                         // row by half the difference between the two rows' length.
280                         // We use the position of the last slot as a proxy for the length, since the
281                         // slots have the same size, and both rows have the same start point.
282                         float lastRowX = mSlots[lastIndex].getX();
283                         float firstRowX = mSlots[mNumColumns - 1].getX();
284                         mRowsContainer.getChildAt(rowIndex).setTranslationX(
285                                 (firstRowX - lastRowX) / 2);
286                     } else {
287                         mRowsContainer.getChildAt(rowIndex).setTranslationX(0);
288                     }
289                 }
290             });
291         }
292     }
293 
setView(@ullable View view, FrameLayout container)294     private void setView(@Nullable View view, FrameLayout container) {
295         container.removeAllViews();
296         if (view != null) {
297             ViewGroup parent = (ViewGroup) view.getParent();
298             // As we are removing views (on BT disconnect, for example), some items will be
299             // shifting from expanded to collapsed (like Queue item) - remove those from the
300             // group before adding to the new slot
301             if (view.getParent() != null) {
302                 parent.removeView(view);
303             }
304             container.addView(view);
305             container.setVisibility(VISIBLE);
306         } else {
307             container.setVisibility(INVISIBLE);
308         }
309     }
310 
onExpandCollapse()311     private void onExpandCollapse() {
312         mIsExpanded = !mIsExpanded;
313         if (mExpandCollapseView != null) {
314             mExpandCollapseView.setSelected(mIsExpanded);
315         }
316         if (mExpandCollapseCallback != null) {
317             mExpandCollapseCallback.onExpandCollapse(mIsExpanded);
318         }
319         mSlots[getSlotIndex(SLOT_EXPAND_COLLAPSE)].setActivated(mIsExpanded);
320 
321         int animationDuration = getContext().getResources().getInteger(mIsExpanded
322                 ? R.integer.control_bar_expand_anim_duration
323                 : R.integer.control_bar_collapse_anim_duration);
324         TransitionSet set = new TransitionSet()
325                 .addTransition(new ChangeBounds())
326                 .addTransition(new Fade())
327                 .setDuration(animationDuration)
328                 .setInterpolator(new FastOutSlowInInterpolator());
329         TransitionManager.beginDelayedTransition(this, set);
330         for (int i = 0; i < mNumExtraRowsInUse; i++) {
331             mRowsContainer.getChildAt(i).setVisibility(mIsExpanded ? View.VISIBLE : View.GONE);
332         }
333     }
334 
335     /**
336      * Returns the view assigned to the given row and column, after layout.
337      *
338      * @param rowIdx row index from 0 being the top row, and {@link #mNumRows{ -1 being the bottom
339      *               row.
340      * @param colIdx column index from 0 on start (left), to {@link #mNumColumns} on end (right)
341      */
342     @VisibleForTesting
343     @Nullable
getViewAt(int rowIdx, int colIdx)344     View getViewAt(int rowIdx, int colIdx) {
345         if (rowIdx < 0 || rowIdx > mRowsContainer.getChildCount()) {
346             throw new IllegalArgumentException(String.format((Locale) null,
347                     "Row index out of range (requested: %d, max: %d)",
348                     rowIdx, mRowsContainer.getChildCount()));
349         }
350         if (colIdx < 0 || colIdx > mNumColumns) {
351             throw new IllegalArgumentException(String.format((Locale) null,
352                     "Column index out of range (requested: %d, max: %d)",
353                     colIdx, mNumColumns));
354         }
355         FrameLayout slot = (FrameLayout) ((LinearLayout) mRowsContainer.getChildAt(rowIdx))
356                 .getChildAt(colIdx + 1);
357         return slot.getChildCount() > 0 ? slot.getChildAt(0) : null;
358     }
359 }
360