• Home
  • History
  • Annotate
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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.qc.view;
18 
19 import static com.android.car.qc.QCItem.QC_ACTION_SLIDER_VALUE;
20 import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE;
21 import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH;
22 import static com.android.car.qc.view.QCView.QCActionListener;
23 
24 import android.app.PendingIntent;
25 import android.content.Context;
26 import android.content.Intent;
27 import android.graphics.drawable.Drawable;
28 import android.os.Handler;
29 import android.text.BidiFormatter;
30 import android.text.TextDirectionHeuristics;
31 import android.text.TextUtils;
32 import android.util.AttributeSet;
33 import android.util.Log;
34 import android.view.KeyEvent;
35 import android.view.LayoutInflater;
36 import android.view.MotionEvent;
37 import android.view.View;
38 import android.view.ViewGroup;
39 import android.widget.FrameLayout;
40 import android.widget.ImageView;
41 import android.widget.LinearLayout;
42 import android.widget.SeekBar;
43 import android.widget.Switch;
44 import android.widget.TextView;
45 
46 import androidx.annotation.ColorInt;
47 import androidx.annotation.LayoutRes;
48 import androidx.annotation.NonNull;
49 import androidx.annotation.Nullable;
50 
51 import com.android.car.qc.QCActionItem;
52 import com.android.car.qc.QCCategory;
53 import com.android.car.qc.QCItem;
54 import com.android.car.qc.QCRow;
55 import com.android.car.qc.QCSlider;
56 import com.android.car.qc.R;
57 import com.android.car.ui.utils.CarUiUtils;
58 import com.android.car.ui.utils.DirectManipulationHelper;
59 import com.android.car.ui.uxr.DrawableStateToggleButton;
60 
61 /**
62  * Quick Controls view for {@link QCRow} instances.
63  */
64 public class QCRowView extends FrameLayout {
65     private static final String TAG = "QCRowView";
66 
67     private LayoutInflater mLayoutInflater;
68     private BidiFormatter mBidiFormatter;
69     private View mContentView;
70     private TextView mTitle;
71     private TextView mSubtitle;
72     private TextView mActionText;
73     private ImageView mStartIcon;
74     @ColorInt
75     private int mStartIconTint;
76     private LinearLayout mStartItemsContainer;
77     private LinearLayout mEndItemsContainer;
78     private LinearLayout mSeekBarContainer;
79     @Nullable
80     private QCSlider mQCSlider;
81     private QCSeekBarView mSeekBar;
82     private QCActionListener mActionListener;
83     private boolean mInDirectManipulationMode;
84 
85     private QCSeekbarChangeListener mSeekbarChangeListener;
86     private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() {
87         @Override
88         public boolean onKey(View v, int keyCode, KeyEvent event) {
89             if (mSeekBar == null || (!mSeekBar.isEnabled()
90                     && !mSeekBar.isClickableWhileDisabled())) {
91                 return false;
92             }
93             // Consume nudge events in direct manipulation mode.
94             if (mInDirectManipulationMode
95                     && (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
96                     || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
97                     || keyCode == KeyEvent.KEYCODE_DPAD_UP
98                     || keyCode == KeyEvent.KEYCODE_DPAD_DOWN)) {
99                 return true;
100             }
101 
102             // Handle events to enter or exit direct manipulation mode.
103             if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
104                 if (event.getAction() == KeyEvent.ACTION_DOWN) {
105                     if (mQCSlider != null) {
106                         if (mQCSlider.isEnabled()) {
107                             setInDirectManipulationMode(v, mSeekBar, !mInDirectManipulationMode);
108                         } else {
109                             fireAction(mQCSlider, new Intent());
110                         }
111                     }
112                 }
113                 return true;
114             }
115             if (keyCode == KeyEvent.KEYCODE_BACK) {
116                 if (mInDirectManipulationMode) {
117                     if (event.getAction() == KeyEvent.ACTION_DOWN) {
118                         setInDirectManipulationMode(v, mSeekBar, false);
119                     }
120                     return true;
121                 }
122             }
123 
124             // Don't propagate confirm keys to the SeekBar to prevent a ripple effect on the thumb.
125             if (KeyEvent.isConfirmKey(keyCode)) {
126                 return false;
127             }
128 
129             if (event.getAction() == KeyEvent.ACTION_DOWN) {
130                 return mSeekBar.onKeyDown(keyCode, event);
131             } else {
132                 return mSeekBar.onKeyUp(keyCode, event);
133             }
134         }
135     };
136 
137     private final View.OnFocusChangeListener mSeekBarFocusChangeListener =
138             (v, hasFocus) -> {
139                 if (!hasFocus && mInDirectManipulationMode && mSeekBar != null) {
140                     setInDirectManipulationMode(v, mSeekBar, false);
141                 }
142             };
143 
144     private final View.OnGenericMotionListener mSeekBarScrollListener =
145             (v, event) -> {
146                 if (!mInDirectManipulationMode || mSeekBar == null) {
147                     return false;
148                 }
149                 int adjustment = Math.round(event.getAxisValue(MotionEvent.AXIS_SCROLL));
150                 if (adjustment == 0) {
151                     return false;
152                 }
153                 int count = Math.abs(adjustment);
154                 int keyCode =
155                         adjustment < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT;
156                 KeyEvent downEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
157                         KeyEvent.ACTION_DOWN, keyCode, /* repeat= */ 0);
158                 KeyEvent upEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
159                         KeyEvent.ACTION_UP, keyCode, /* repeat= */ 0);
160                 for (int i = 0; i < count; i++) {
161                     mSeekBar.onKeyDown(keyCode, downEvent);
162                     mSeekBar.onKeyUp(keyCode, upEvent);
163                 }
164                 return true;
165             };
166 
QCRowView(Context context)167     QCRowView(Context context) {
168         super(context);
169         init(context);
170     }
171 
QCRowView(Context context, AttributeSet attrs)172     QCRowView(Context context, AttributeSet attrs) {
173         super(context, attrs);
174         init(context);
175     }
176 
QCRowView(Context context, AttributeSet attrs, int defStyleAttr)177     QCRowView(Context context, AttributeSet attrs, int defStyleAttr) {
178         super(context, attrs, defStyleAttr);
179         init(context);
180     }
181 
QCRowView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)182     QCRowView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
183         super(context, attrs, defStyleAttr, defStyleRes);
184         init(context);
185     }
186 
init(Context context)187     private void init(Context context) {
188         mLayoutInflater = LayoutInflater.from(context);
189         mBidiFormatter = BidiFormatter.getInstance();
190         mLayoutInflater.inflate(R.layout.qc_row_view, /* root= */ this);
191         mContentView = findViewById(R.id.qc_row_content);
192         mTitle = findViewById(R.id.qc_title);
193         mSubtitle = findViewById(R.id.qc_summary);
194         mActionText = findViewById(R.id.qc_action_text);
195         mStartIcon = findViewById(R.id.qc_icon);
196         mStartItemsContainer = findViewById(R.id.qc_row_start_items);
197         mEndItemsContainer = findViewById(R.id.qc_row_end_items);
198         mSeekBarContainer = findViewById(R.id.qc_seekbar_wrapper);
199         mSeekBar = findViewById(R.id.qc_seekbar);
200     }
201 
setActionListener(QCActionListener listener)202     void setActionListener(QCActionListener listener) {
203         mActionListener = listener;
204     }
205 
setRow(QCRow row)206     void setRow(QCRow row) {
207         if (row == null) {
208             setVisibility(GONE);
209             return;
210         }
211         setVisibility(VISIBLE);
212         CarUiUtils.makeAllViewsEnabled(mContentView, row.isEnabled());
213         if (!row.isEnabled()) {
214             if (row.isClickableWhileDisabled() && (row.getDisabledClickAction() != null
215                     || row.getDisabledClickActionHandler() != null)) {
216                 mContentView.setOnClickListener(v -> {
217                     fireAction(row, /* intent= */ null);
218                 });
219             } else {
220                 mContentView.setOnClickListener(null);
221             }
222         } else if (row.getPrimaryAction() != null || row.getActionHandler() != null) {
223             mContentView.setOnClickListener(v -> {
224                 fireAction(row, /* intent= */ null);
225             });
226         } else {
227             mContentView.setOnClickListener(null);
228         }
229         if (!TextUtils.isEmpty(row.getTitle())) {
230             mTitle.setVisibility(VISIBLE);
231             mTitle.setText(
232                     mBidiFormatter.unicodeWrap(row.getTitle(), TextDirectionHeuristics.LOCALE));
233         } else {
234             mTitle.setVisibility(GONE);
235         }
236         if (!TextUtils.isEmpty(row.getSubtitle())) {
237             mSubtitle.setVisibility(VISIBLE);
238             mSubtitle.setText(
239                     mBidiFormatter.unicodeWrap(row.getSubtitle(), TextDirectionHeuristics.LOCALE));
240         } else {
241             mSubtitle.setVisibility(GONE);
242         }
243         if (!TextUtils.isEmpty(row.getActionText())) {
244             mActionText.setVisibility(VISIBLE);
245             mActionText.setText(
246                     mBidiFormatter.unicodeWrap(row.getActionText(),
247                             TextDirectionHeuristics.LOCALE));
248             if (row.getCategory() == QCCategory.WARNING) {
249                 mActionText.setTextColor(
250                         getResources().getColor(R.color.qc_warning_text_color));
251             } else {
252                 mActionText.setTextColor(
253                         getResources().getColor(
254                                 com.android.car.resource.common.R.color.car_on_surface_variant));
255             }
256         } else {
257             mActionText.setVisibility(GONE);
258         }
259         if (row.getStartIcon() != null) {
260             mStartIcon.setVisibility(VISIBLE);
261             Drawable drawable = row.getStartIcon().loadDrawable(getContext());
262             if (drawable != null && row.isStartIconTintable()) {
263                 if (mStartIconTint == 0) {
264                     mStartIconTint = getContext().getColor(R.color.qc_start_icon_color);
265                 }
266                 drawable.setTint(mStartIconTint);
267             }
268             mStartIcon.setImageDrawable(drawable);
269         } else {
270             mStartIcon.setImageDrawable(null);
271             mStartIcon.setVisibility(GONE);
272         }
273         QCSlider slider = row.getSlider();
274         if (slider != null) {
275             mSeekBarContainer.setVisibility(View.VISIBLE);
276             initSlider(slider);
277         } else {
278             mSeekBarContainer.setVisibility(View.GONE);
279             mQCSlider = null;
280         }
281 
282         int startItemCount = row.getStartItems().size();
283         for (int i = 0; i < startItemCount; i++) {
284             QCActionItem action = row.getStartItems().get(i);
285             initActionItem(mStartItemsContainer, mStartItemsContainer.getChildAt(i), action);
286         }
287         if (mStartItemsContainer.getChildCount() > startItemCount) {
288             // remove extra items
289             mStartItemsContainer.removeViews(startItemCount,
290                     mStartItemsContainer.getChildCount() - startItemCount);
291         }
292         if (startItemCount == 0) {
293             mStartItemsContainer.setVisibility(View.GONE);
294         } else {
295             mStartItemsContainer.setVisibility(View.VISIBLE);
296         }
297 
298         int endItemCount = row.getEndItems().size();
299         for (int i = 0; i < endItemCount; i++) {
300             QCActionItem action = row.getEndItems().get(i);
301             initActionItem(mEndItemsContainer, mEndItemsContainer.getChildAt(i), action);
302         }
303         if (mEndItemsContainer.getChildCount() > endItemCount) {
304             // remove extra items
305             mEndItemsContainer.removeViews(endItemCount,
306                     mEndItemsContainer.getChildCount() - endItemCount);
307         }
308         if (endItemCount == 0) {
309             mEndItemsContainer.setVisibility(View.GONE);
310         } else {
311             mEndItemsContainer.setVisibility(View.VISIBLE);
312         }
313     }
314 
initActionItem(@onNull ViewGroup root, @Nullable View actionView, @NonNull QCActionItem action)315     private void initActionItem(@NonNull ViewGroup root, @Nullable View actionView,
316             @NonNull QCActionItem action) {
317         if (action.getType().equals(QC_TYPE_ACTION_SWITCH)) {
318             initSwitchView(action, root, actionView);
319         } else {
320             initToggleView(action, root, actionView);
321         }
322     }
323 
initSwitchView(QCActionItem action, ViewGroup root, View actionView)324     private void initSwitchView(QCActionItem action, ViewGroup root, View actionView) {
325         Switch switchView = actionView == null ? null : actionView.findViewById(
326                 android.R.id.switch_widget);
327         if (switchView == null) {
328             actionView = createActionView(root, actionView, R.layout.qc_action_switch);
329             switchView = actionView.requireViewById(android.R.id.switch_widget);
330         }
331         CarUiUtils.makeAllViewsEnabled(switchView, action.isEnabled());
332 
333         boolean shouldEnableView =
334                 (action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
335                 && action.isClickable();
336         switchView.setOnCheckedChangeListener(null);
337         switchView.setEnabled(shouldEnableView);
338         switchView.setChecked(action.isChecked());
339         switchView.setContentDescription(action.getContentDescription());
340         switchView.setOnTouchListener((v, event) -> {
341             if (!action.isEnabled()) {
342                 if (event.getActionMasked() == MotionEvent.ACTION_UP) {
343                     fireAction(action, new Intent());
344                 }
345                 return true;
346             }
347             return false;
348         });
349         switchView.setOnCheckedChangeListener(
350                 (buttonView, isChecked) -> {
351                     Intent intent = new Intent();
352                     intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
353                     fireAction(action, intent);
354                 });
355     }
356 
initToggleView(QCActionItem action, ViewGroup root, View actionView)357     private void initToggleView(QCActionItem action, ViewGroup root, View actionView) {
358         DrawableStateToggleButton tmpToggleButton =
359                 actionView == null ? null : actionView.findViewById(R.id.qc_toggle_button);
360         if (tmpToggleButton == null) {
361             actionView = createActionView(root, actionView, R.layout.qc_action_toggle);
362             tmpToggleButton = actionView.requireViewById(R.id.qc_toggle_button);
363         }
364         DrawableStateToggleButton toggleButton = tmpToggleButton; // must be effectively final
365         boolean shouldEnableView =
366                 (action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
367                 && action.isClickable();
368         toggleButton.setText(null);
369         toggleButton.setTextOn(null);
370         toggleButton.setTextOff(null);
371         toggleButton.setOnCheckedChangeListener(null);
372         Drawable icon = QCViewUtils.getToggleIcon(mContext, action.getIcon(), action.isAvailable());
373         toggleButton.setContentDescription(action.getContentDescription());
374         toggleButton.setButtonDrawable(icon);
375         toggleButton.setChecked(action.isChecked());
376         toggleButton.setEnabled(shouldEnableView);
377         setToggleButtonDrawableState(toggleButton, action.isEnabled(), action.isAvailable());
378         toggleButton.setOnTouchListener((v, event) -> {
379             if (!action.isEnabled()) {
380                 if (event.getActionMasked() == MotionEvent.ACTION_UP) {
381                     fireAction(action, new Intent());
382                 }
383                 return true;
384             }
385             return false;
386         });
387         toggleButton.setOnCheckedChangeListener(
388                 (buttonView, isChecked) -> {
389                     Intent intent = new Intent();
390                     intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
391                     fireAction(action, intent);
392                 });
393     }
394 
setToggleButtonDrawableState(DrawableStateToggleButton view, boolean enabled, boolean available)395     private void setToggleButtonDrawableState(DrawableStateToggleButton view,
396             boolean enabled, boolean available) {
397         int[] statesToAdd = null;
398         int[] statesToRemove = null;
399         if (enabled) {
400             if (!available) {
401                 statesToAdd =
402                         new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
403             } else {
404                 statesToAdd = new int[]{android.R.attr.state_enabled};
405                 statesToRemove = new int[]{R.attr.state_toggle_unavailable};
406             }
407         } else {
408             if (available) {
409                 statesToRemove =
410                         new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
411             } else {
412                 statesToAdd = new int[]{R.attr.state_toggle_unavailable};
413                 statesToRemove = new int[]{android.R.attr.state_enabled};
414             }
415         }
416         CarUiUtils.applyDrawableStatesToAllViews(view, statesToAdd, statesToRemove);
417     }
418 
419     @NonNull
createActionView(@onNull ViewGroup root, @Nullable View actionView, @LayoutRes int resId)420     private View createActionView(@NonNull ViewGroup root, @Nullable View actionView,
421             @LayoutRes int resId) {
422         if (actionView != null) {
423             // remove current action view
424             root.removeView(actionView);
425         }
426         actionView = mLayoutInflater.inflate(resId, root, /* attachToRoot= */ false);
427         root.addView(actionView);
428         return actionView;
429     }
430 
initSlider(QCSlider slider)431     private void initSlider(QCSlider slider) {
432         mQCSlider = slider;
433         CarUiUtils.makeAllViewsEnabled(mSeekBar, slider.isEnabled());
434 
435         mSeekBar.setOnSeekBarChangeListener(null);
436         mSeekBar.setMin(slider.getMin());
437         mSeekBar.setMax(slider.getMax());
438         mSeekBar.setProgress(slider.getValue());
439         mSeekBar.setEnabled(slider.isEnabled());
440         mSeekBar.setClickableWhileDisabled(slider.isClickableWhileDisabled());
441         mSeekBar.setDisabledClickListener(seekBar -> fireAction(slider, new Intent()));
442         if (!slider.isEnabled() && mInDirectManipulationMode) {
443             setInDirectManipulationMode(mSeekBarContainer, mSeekBar, false);
444         }
445         if (mSeekbarChangeListener == null) {
446             mSeekbarChangeListener = new QCSeekbarChangeListener();
447         }
448         mSeekbarChangeListener.setSlider(slider);
449         mSeekBar.setOnSeekBarChangeListener(mSeekbarChangeListener);
450         // set up rotary support
451         mSeekBarContainer.setOnKeyListener(mSeekBarKeyListener);
452         mSeekBarContainer.setOnFocusChangeListener(mSeekBarFocusChangeListener);
453         mSeekBarContainer.setOnGenericMotionListener(mSeekBarScrollListener);
454     }
455 
setInDirectManipulationMode(View view, SeekBar seekbar, boolean enable)456     private void setInDirectManipulationMode(View view, SeekBar seekbar, boolean enable) {
457         mInDirectManipulationMode = enable;
458         DirectManipulationHelper.enableDirectManipulationMode(seekbar, enable);
459         view.setSelected(enable);
460         seekbar.setSelected(enable);
461     }
462 
fireAction(QCItem item, Intent intent)463     private void fireAction(QCItem item, Intent intent) {
464         if (!item.isEnabled()) {
465             if (item.getDisabledClickAction() != null) {
466                 try {
467                     item.getDisabledClickAction().send(getContext(), 0, intent);
468                     if (mActionListener != null) {
469                         mActionListener.onQCAction(item, item.getDisabledClickAction());
470                     }
471                 } catch (PendingIntent.CanceledException e) {
472                     Log.d(TAG, "Error sending intent", e);
473                 }
474             } else if (item.getDisabledClickActionHandler() != null) {
475                 item.getDisabledClickActionHandler().onAction(item, getContext(), intent);
476                 if (mActionListener != null) {
477                     mActionListener.onQCAction(item, item.getDisabledClickActionHandler());
478                 }
479             }
480             return;
481         }
482 
483         if (item.getPrimaryAction() != null) {
484             try {
485                 item.getPrimaryAction().send(getContext(), 0, intent);
486                 if (mActionListener != null) {
487                     mActionListener.onQCAction(item, item.getPrimaryAction());
488                 }
489             } catch (PendingIntent.CanceledException e) {
490                 Log.d(TAG, "Error sending intent", e);
491             }
492         } else if (item.getActionHandler() != null) {
493             item.getActionHandler().onAction(item, getContext(), intent);
494             if (mActionListener != null) {
495                 mActionListener.onQCAction(item, item.getActionHandler());
496             }
497         }
498     }
499 
500     private class QCSeekbarChangeListener implements SeekBar.OnSeekBarChangeListener {
501         // Interval of updates (in ms) sent in response to seekbar moving.
502         private static final int SLIDER_UPDATE_INTERVAL = 200;
503 
504         private final Handler mSliderUpdateHandler;
505         private QCSlider mSlider;
506         private int mCurrSliderValue;
507         private boolean mSliderUpdaterRunning;
508         private long mLastSentSliderUpdate;
509         private final Runnable mSliderUpdater = () -> {
510             sendSliderValue();
511             mSliderUpdaterRunning = false;
512         };
513 
QCSeekbarChangeListener()514         QCSeekbarChangeListener() {
515             mSliderUpdateHandler = new Handler();
516         }
517 
setSlider(QCSlider slider)518         void setSlider(QCSlider slider) {
519             mSlider = slider;
520         }
521 
522         @Override
onProgressChanged(SeekBar seekBar, int progress, boolean fromUser)523         public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
524             mCurrSliderValue = progress;
525             long now = System.currentTimeMillis();
526             if (mLastSentSliderUpdate != 0
527                     && now - mLastSentSliderUpdate > SLIDER_UPDATE_INTERVAL) {
528                 mSliderUpdaterRunning = false;
529                 mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
530                 sendSliderValue();
531             } else if (!mSliderUpdaterRunning) {
532                 mSliderUpdaterRunning = true;
533                 mSliderUpdateHandler.postDelayed(mSliderUpdater, SLIDER_UPDATE_INTERVAL);
534             }
535         }
536 
537         @Override
onStartTrackingTouch(SeekBar seekBar)538         public void onStartTrackingTouch(SeekBar seekBar) {
539         }
540 
541         @Override
onStopTrackingTouch(SeekBar seekBar)542         public void onStopTrackingTouch(SeekBar seekBar) {
543             if (mSliderUpdaterRunning) {
544                 mSliderUpdaterRunning = false;
545                 mSliderUpdateHandler.removeCallbacks(mSliderUpdater);
546             }
547             mCurrSliderValue = seekBar.getProgress();
548             sendSliderValue();
549         }
550 
sendSliderValue()551         private void sendSliderValue() {
552             if (mSlider == null) {
553                 return;
554             }
555             mLastSentSliderUpdate = System.currentTimeMillis();
556             Intent intent = new Intent();
557             intent.putExtra(QC_ACTION_SLIDER_VALUE, mCurrSliderValue);
558             fireAction(mSlider, intent);
559         }
560     }
561 }
562