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