1 /*
<lambda>null2 * 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.systemui.qs.tileimpl
18
19 import android.animation.ArgbEvaluator
20 import android.animation.PropertyValuesHolder
21 import android.animation.ValueAnimator
22 import android.annotation.SuppressLint
23 import android.content.Context
24 import android.content.res.ColorStateList
25 import android.content.res.Configuration
26 import android.content.res.Resources.ID_NULL
27 import android.graphics.Color
28 import android.graphics.PorterDuff
29 import android.graphics.Rect
30 import android.graphics.drawable.Drawable
31 import android.graphics.drawable.GradientDrawable
32 import android.graphics.drawable.LayerDrawable
33 import android.graphics.drawable.RippleDrawable
34 import android.os.Trace
35 import android.service.quicksettings.Tile
36 import android.text.TextUtils
37 import android.util.Log
38 import android.util.TypedValue
39 import android.view.Gravity
40 import android.view.LayoutInflater
41 import android.view.MotionEvent
42 import android.view.View
43 import android.view.ViewConfiguration
44 import android.view.ViewGroup
45 import android.view.accessibility.AccessibilityEvent
46 import android.view.accessibility.AccessibilityNodeInfo
47 import android.view.animation.AccelerateDecelerateInterpolator
48 import android.widget.Button
49 import android.widget.ImageView
50 import android.widget.LinearLayout
51 import android.widget.Switch
52 import android.widget.TextView
53 import androidx.annotation.VisibleForTesting
54 import androidx.core.animation.doOnCancel
55 import androidx.core.animation.doOnEnd
56 import androidx.core.animation.doOnStart
57 import androidx.core.graphics.drawable.updateBounds
58 import com.android.app.tracing.traceSection
59 import com.android.settingslib.Utils
60 import com.android.systemui.Flags
61 import com.android.systemui.FontSizeUtils
62 import com.android.systemui.animation.Expandable
63 import com.android.systemui.animation.LaunchableView
64 import com.android.systemui.animation.LaunchableViewDelegate
65 import com.android.systemui.haptics.qs.QSLongPressEffect
66 import com.android.systemui.plugins.qs.QSIconView
67 import com.android.systemui.plugins.qs.QSTile
68 import com.android.systemui.plugins.qs.QSTile.AdapterState
69 import com.android.systemui.plugins.qs.QSTileView
70 import com.android.systemui.qs.logging.QSLogger
71 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
72 import com.android.systemui.res.R
73 import java.util.Objects
74
75 private const val TAG = "QSTileViewImpl"
76
77 open class QSTileViewImpl
78 @JvmOverloads
79 constructor(
80 context: Context,
81 private val collapsed: Boolean = false,
82 private val longPressEffect: QSLongPressEffect? = null,
83 ) : QSTileView(context), HeightOverrideable, LaunchableView {
84
85 companion object {
86 private const val INVALID = -1
87 private const val BACKGROUND_NAME = "background"
88 private const val LABEL_NAME = "label"
89 private const val SECONDARY_LABEL_NAME = "secondaryLabel"
90 private const val CHEVRON_NAME = "chevron"
91 private const val OVERLAY_NAME = "overlay"
92 const val UNAVAILABLE_ALPHA = 0.3f
93 @VisibleForTesting internal const val TILE_STATE_RES_PREFIX = "tile_states_"
94 @VisibleForTesting internal const val LONG_PRESS_EFFECT_WIDTH_SCALE = 1.1f
95 @VisibleForTesting internal const val LONG_PRESS_EFFECT_HEIGHT_SCALE = 1.2f
96 }
97
98 private val icon: QSIconViewImpl = QSIconViewImpl(context)
99 private var position: Int = INVALID
100
101 override fun setPosition(position: Int) {
102 this.position = position
103 }
104
105 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
106 set(value) {
107 if (field == value) return
108 field = value
109 updateHeight()
110 }
111
112 override var squishinessFraction: Float = 1f
113 set(value) {
114 if (field == value) return
115 field = value
116 updateHeight()
117 }
118
119 private val colorActive = Utils.getColorAttrDefaultColor(context, R.attr.shadeActive)
120 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.shadeInactive)
121 private val colorUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.shadeDisabled)
122
123 private val overlayColorActive =
124 Utils.applyAlpha(
125 /* alpha= */ 0.11f,
126 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive)
127 )
128 private val overlayColorInactive =
129 Utils.applyAlpha(
130 /* alpha= */ 0.08f,
131 Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive)
132 )
133
134 private val colorLabelActive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive)
135 private val colorLabelInactive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive)
136 private val colorLabelUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.outline)
137
138 private val colorSecondaryLabelActive =
139 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActiveVariant)
140 private val colorSecondaryLabelInactive =
141 Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactiveVariant)
142 private val colorSecondaryLabelUnavailable =
143 Utils.getColorAttrDefaultColor(context, R.attr.outline)
144
145 private lateinit var label: TextView
146 protected lateinit var secondaryLabel: TextView
147 private lateinit var labelContainer: IgnorableChildLinearLayout
148 protected lateinit var sideView: ViewGroup
149 private lateinit var customDrawableView: ImageView
150 private lateinit var chevronView: ImageView
151 private var mQsLogger: QSLogger? = null
152
153 /** Controls if tile background is set to a [RippleDrawable] see [setClickable] */
154 protected var showRippleEffect = true
155
156 private lateinit var qsTileBackground: RippleDrawable
157 private lateinit var qsTileFocusBackground: Drawable
158 private lateinit var backgroundDrawable: LayerDrawable
159 private lateinit var backgroundBaseDrawable: Drawable
160 private lateinit var backgroundOverlayDrawable: Drawable
161
162 private var backgroundColor: Int = 0
163 private var backgroundOverlayColor: Int = 0
164
165 private val singleAnimator: ValueAnimator =
166 ValueAnimator().apply {
167 setDuration(QS_ANIM_LENGTH)
168 addUpdateListener { animation ->
169 setAllColors(
170 // These casts will throw an exception if some property is missing. We should
171 // always have all properties.
172 animation.getAnimatedValue(BACKGROUND_NAME) as Int,
173 animation.getAnimatedValue(LABEL_NAME) as Int,
174 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
175 animation.getAnimatedValue(CHEVRON_NAME) as Int,
176 animation.getAnimatedValue(OVERLAY_NAME) as Int,
177 )
178 }
179 }
180
181 private var accessibilityClass: String? = null
182 private var stateDescriptionDeltas: CharSequence? = null
183 private var lastStateDescription: CharSequence? = null
184 private var tileState = false
185 private var lastState = INVALID
186 private var lastIconTint = 0
187 private val launchableViewDelegate =
188 LaunchableViewDelegate(
189 this,
190 superSetVisibility = { super.setVisibility(it) },
191 )
192 private var lastDisabledByPolicy = false
193
194 private val locInScreen = IntArray(2)
195
196 /** Visuo-haptic long-press effects */
197 private var longPressEffectAnimator: ValueAnimator? = null
198 var haveLongPressPropertiesBeenReset = true
199 private set
200
201 private var paddingForLaunch = Rect()
202 private var initialLongPressProperties: QSLongPressProperties? = null
203 private var finalLongPressProperties: QSLongPressProperties? = null
204 private val colorEvaluator = ArgbEvaluator.getInstance()
205 val isLongPressEffectInitialized: Boolean
206 get() = longPressEffect?.hasInitialized == true
207
208 val areLongPressEffectPropertiesSet: Boolean
209 get() = initialLongPressProperties != null && finalLongPressProperties != null
210
211 init {
212 val typedValue = TypedValue()
213 if (!getContext().theme.resolveAttribute(R.attr.isQsTheme, typedValue, true)) {
214 throw IllegalStateException(
215 "QSViewImpl must be inflated with a theme that contains " +
216 "Theme.SystemUI.QuickSettings"
217 )
218 }
219 setId(generateViewId())
220 orientation = LinearLayout.HORIZONTAL
221 gravity = Gravity.CENTER_VERTICAL or Gravity.START
222 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
223 clipChildren = false
224 clipToPadding = false
225 isFocusable = true
226 background = createTileBackground()
227 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
228
229 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
230 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
231 setPaddingRelative(startPadding, padding, padding, padding)
232
233 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
234 addView(icon, LayoutParams(iconSize, iconSize))
235
236 createAndAddLabels()
237 createAndAddSideView()
238 }
239
240 override fun onConfigurationChanged(newConfig: Configuration?) {
241 super.onConfigurationChanged(newConfig)
242 updateResources()
243 }
244
245 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
246 Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure")
247 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
248 Trace.endSection()
249 }
250
251 override fun resetOverride() {
252 heightOverride = HeightOverrideable.NO_OVERRIDE
253 updateHeight()
254 }
255
256 fun setQsLogger(qsLogger: QSLogger) {
257 mQsLogger = qsLogger
258 }
259
260 fun updateResources() {
261 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
262 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
263
264 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
265 icon.layoutParams.apply {
266 height = iconSize
267 width = iconSize
268 }
269
270 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
271 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
272 setPaddingRelative(startPadding, padding, padding, padding)
273
274 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
275 (labelContainer.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
276
277 (sideView.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
278 (chevronView.layoutParams as MarginLayoutParams).apply {
279 height = iconSize
280 width = iconSize
281 }
282
283 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
284 (customDrawableView.layoutParams as MarginLayoutParams).apply {
285 height = iconSize
286 marginEnd = endMargin
287 }
288
289 background = createTileBackground()
290 setColor(backgroundColor)
291 setOverlayColor(backgroundOverlayColor)
292 }
293
294 private fun createAndAddLabels() {
295 labelContainer =
296 LayoutInflater.from(context).inflate(R.layout.qs_tile_label, this, false)
297 as IgnorableChildLinearLayout
298 label = labelContainer.requireViewById(R.id.tile_label)
299 secondaryLabel = labelContainer.requireViewById(R.id.app_label)
300 if (collapsed) {
301 labelContainer.ignoreLastView = true
302 // Ideally, it'd be great if the parent could set this up when measuring just this child
303 // instead of the View class having to support this. However, due to the mysteries of
304 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
305 // sibling methods to have special behavior for labelContainer.
306 labelContainer.forceUnspecifiedMeasure = true
307 secondaryLabel.alpha = 0f
308 }
309 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
310 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
311 addView(labelContainer)
312 }
313
314 private fun createAndAddSideView() {
315 sideView =
316 LayoutInflater.from(context).inflate(R.layout.qs_tile_side_icon, this, false)
317 as ViewGroup
318 customDrawableView = sideView.requireViewById(R.id.customDrawable)
319 chevronView = sideView.requireViewById(R.id.chevron)
320 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
321 addView(sideView)
322 }
323
324 private fun createTileBackground(): Drawable {
325 qsTileBackground =
326 if (Flags.qsTileFocusState()) {
327 mContext.getDrawable(R.drawable.qs_tile_background_flagged) as RippleDrawable
328 } else {
329 mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
330 }
331 qsTileFocusBackground = mContext.getDrawable(R.drawable.qs_tile_focused_background)!!
332 backgroundDrawable =
333 qsTileBackground.findDrawableByLayerId(R.id.background) as LayerDrawable
334 backgroundBaseDrawable =
335 backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_base)
336 backgroundOverlayDrawable =
337 backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_overlay)
338 backgroundOverlayDrawable.mutate().setTintMode(PorterDuff.Mode.SRC)
339 return qsTileBackground
340 }
341
342 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
343 super.onLayout(changed, l, t, r, b)
344 updateHeight()
345 maybeUpdateLongPressEffectWidth(measuredWidth.toFloat())
346 }
347
348 private fun maybeUpdateLongPressEffectWidth(width: Float) {
349 if (!isLongClickable || longPressEffect == null) return
350
351 initialLongPressProperties?.width = width
352 finalLongPressProperties?.width = LONG_PRESS_EFFECT_WIDTH_SCALE * width
353 }
354
355 private fun maybeUpdateLongPressEffectHeight(height: Float) {
356 if (!isLongClickable || longPressEffect == null) return
357
358 initialLongPressProperties?.height = height
359 finalLongPressProperties?.height = LONG_PRESS_EFFECT_HEIGHT_SCALE * height
360 }
361
362 override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
363 super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
364 if (Flags.qsTileFocusState()) {
365 if (gainFocus) {
366 qsTileFocusBackground.setBounds(0, 0, width, height)
367 overlay.add(qsTileFocusBackground)
368 } else {
369 overlay.clear()
370 }
371 }
372 }
373
374 private fun updateHeight() {
375 // TODO(b/332900989): Find a more robust way of resetting the tile if not reset by the
376 // launch animation.
377 if (!haveLongPressPropertiesBeenReset && longPressEffect != null) {
378 // The launch animation of a long-press effect did not reset the long-press effect so
379 // we must do it here
380 resetLongPressEffectProperties()
381 }
382 val actualHeight =
383 if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
384 heightOverride
385 } else {
386 measuredHeight
387 }
388 // Limit how much we affect the height, so we don't have rounding artifacts when the tile
389 // is too short.
390 val constrainedSquishiness = constrainSquishiness(squishinessFraction)
391 bottom = top + (actualHeight * constrainedSquishiness).toInt()
392 scrollY = (actualHeight - height) / 2
393 maybeUpdateLongPressEffectHeight(actualHeight.toFloat())
394 }
395
396 override fun updateAccessibilityOrder(previousView: View?): View {
397 accessibilityTraversalAfter = previousView?.id ?: ID_NULL
398 return this
399 }
400
401 override fun getIcon(): QSIconView {
402 return icon
403 }
404
405 override fun getIconWithBackground(): View {
406 return icon
407 }
408
409 override fun init(tile: QSTile) {
410 val expandable = Expandable.fromView(this)
411 if (longPressEffect != null) {
412 isHapticFeedbackEnabled = false
413 longPressEffect.qsTile = tile
414 longPressEffect.expandable = expandable
415 initLongPressEffectCallback()
416 init(
417 { _: View -> longPressEffect.onTileClick() },
418 null, // Haptics and long-clicks will be handled by the [QSLongPressEffect]
419 )
420 } else {
421 init(
422 { _: View? -> tile.click(expandable) },
423 { _: View? ->
424 tile.longClick(expandable)
425 true
426 },
427 )
428 }
429 }
430
431 private fun initLongPressEffectCallback() {
432 longPressEffect?.callback =
433 object : QSLongPressEffect.Callback {
434
435 override fun onPrepareForLaunch() {
436 prepareForLaunch()
437 }
438
439 override fun onResetProperties() {
440 resetLongPressEffectProperties()
441 }
442
443 override fun onStartAnimator() {
444 if (longPressEffectAnimator?.isRunning != true) {
445 longPressEffectAnimator =
446 ValueAnimator.ofFloat(0f, 1f).apply {
447 this.duration = longPressEffect?.effectDuration?.toLong() ?: 0L
448 interpolator = AccelerateDecelerateInterpolator()
449
450 doOnStart { longPressEffect?.handleAnimationStart() }
451 addUpdateListener {
452 val value = animatedValue as Float
453 if (value == 0f) {
454 bringToFront()
455 } else {
456 updateLongPressEffectProperties(value)
457 }
458 }
459 doOnEnd { longPressEffect?.handleAnimationComplete() }
460 doOnCancel { longPressEffect?.handleAnimationCancel() }
461 start()
462 }
463 }
464 }
465
466 override fun onReverseAnimator() {
467 longPressEffectAnimator?.let {
468 val pausedProgress = it.animatedFraction
469 longPressEffect?.playReverseHaptics(pausedProgress)
470 it.reverse()
471 }
472 }
473
474 override fun onCancelAnimator() {
475 resetLongPressEffectProperties()
476 longPressEffectAnimator?.cancel()
477 }
478 }
479 }
480
481 private fun init(click: OnClickListener?, longClick: OnLongClickListener?) {
482 setOnClickListener(click)
483 onLongClickListener = longClick
484 }
485
486 override fun onStateChanged(state: QSTile.State) {
487 // We cannot use the handler here because sometimes, the views are not attached (if they
488 // are in a page that the ViewPager hasn't attached). Instead, we use a runnable where
489 // all its instances are `equal` to each other, so they can be used to remove them from the
490 // queue.
491 // This means that at any given time there's at most one enqueued runnable to change state.
492 // However, as we only ever care about the last state posted, this is fine.
493 val runnable = StateChangeRunnable(state.copy())
494 removeCallbacks(runnable)
495 post(runnable)
496 }
497
498 override fun getDetailY(): Int {
499 return top + height / 2
500 }
501
502 override fun hasOverlappingRendering(): Boolean {
503 // Avoid layers for this layout - we don't need them.
504 return false
505 }
506
507 override fun setClickable(clickable: Boolean) {
508 super.setClickable(clickable)
509 if (!Flags.qsTileFocusState()) {
510 background =
511 if (clickable && showRippleEffect) {
512 qsTileBackground.also {
513 // In case that the colorBackgroundDrawable was used as the background, make
514 // sure
515 // it has the correct callback instead of null
516 backgroundDrawable.callback = it
517 }
518 } else {
519 backgroundDrawable
520 }
521 }
522 }
523
524 override fun getLabelContainer(): View {
525 return labelContainer
526 }
527
528 override fun getLabel(): View {
529 return label
530 }
531
532 override fun getSecondaryLabel(): View {
533 return secondaryLabel
534 }
535
536 override fun getSecondaryIcon(): View {
537 return sideView
538 }
539
540 override fun setShouldBlockVisibilityChanges(block: Boolean) {
541 launchableViewDelegate.setShouldBlockVisibilityChanges(block)
542 }
543
544 override fun setVisibility(visibility: Int) {
545 launchableViewDelegate.setVisibility(visibility)
546 }
547
548 // Accessibility
549
550 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
551 super.onInitializeAccessibilityEvent(event)
552 if (!TextUtils.isEmpty(accessibilityClass)) {
553 event.className = accessibilityClass
554 }
555 if (
556 event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
557 stateDescriptionDeltas != null
558 ) {
559 event.text.add(stateDescriptionDeltas)
560 stateDescriptionDeltas = null
561 }
562 }
563
564 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
565 super.onInitializeAccessibilityNodeInfo(info)
566 // Clear selected state so it is not announce by talkback.
567 info.isSelected = false
568 info.text =
569 if (TextUtils.isEmpty(secondaryLabel.text)) {
570 "${label.text}"
571 } else {
572 "${label.text}, ${secondaryLabel.text}"
573 }
574 if (lastDisabledByPolicy) {
575 info.addAction(
576 AccessibilityNodeInfo.AccessibilityAction(
577 AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id,
578 resources.getString(
579 R.string.accessibility_tile_disabled_by_policy_action_description
580 )
581 )
582 )
583 }
584 if (!TextUtils.isEmpty(accessibilityClass)) {
585 info.className =
586 if (lastDisabledByPolicy) {
587 Button::class.java.name
588 } else {
589 accessibilityClass
590 }
591 if (Switch::class.java.name == accessibilityClass) {
592 info.isChecked = tileState
593 info.isCheckable = true
594 if (isLongClickable) {
595 info.addAction(
596 AccessibilityNodeInfo.AccessibilityAction(
597 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
598 resources.getString(R.string.accessibility_long_click_tile)
599 )
600 )
601 }
602 }
603 }
604 if (position != INVALID) {
605 info.collectionItemInfo =
606 AccessibilityNodeInfo.CollectionItemInfo(position, 1, 0, 1, false)
607 }
608 }
609
610 override fun toString(): String {
611 val sb = StringBuilder(javaClass.simpleName).append('[')
612 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
613 sb.append(", iconView=$icon")
614 sb.append(", tileState=$tileState")
615 sb.append("]")
616 return sb.toString()
617 }
618
619 @SuppressLint("ClickableViewAccessibility")
620 override fun onTouchEvent(event: MotionEvent?): Boolean {
621 // let the View run the onTouch logic for click and long-click detection
622 val result = super.onTouchEvent(event)
623 if (longPressEffect != null) {
624 when (event?.actionMasked) {
625 MotionEvent.ACTION_DOWN -> {
626 longPressEffect.handleActionDown()
627 if (isLongClickable) {
628 postDelayed(
629 { longPressEffect.handleTimeoutComplete() },
630 ViewConfiguration.getTapTimeout().toLong(),
631 )
632 }
633 }
634 MotionEvent.ACTION_UP -> longPressEffect.handleActionUp()
635 MotionEvent.ACTION_CANCEL -> longPressEffect.handleActionCancel()
636 }
637 }
638 return result
639 }
640
641 // HANDLE STATE CHANGES RELATED METHODS
642
643 protected open fun handleStateChanged(state: QSTile.State) {
644 val allowAnimations = animationsEnabled()
645 isClickable = state.state != Tile.STATE_UNAVAILABLE
646 isLongClickable = state.handlesLongClick
647 icon.setIcon(state, allowAnimations)
648 contentDescription = state.contentDescription
649
650 // State handling and description
651 val stateDescription = StringBuilder()
652 val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
653 val stateText = state.getStateText(arrayResId, resources)
654 state.secondaryLabel = state.getSecondaryLabel(stateText)
655 if (!TextUtils.isEmpty(stateText)) {
656 stateDescription.append(stateText)
657 }
658 if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) {
659 stateDescription.append(", ")
660 stateDescription.append(getUnavailableText(state.spec))
661 }
662 if (!TextUtils.isEmpty(state.stateDescription)) {
663 stateDescription.append(", ")
664 stateDescription.append(state.stateDescription)
665 if (
666 lastState != INVALID &&
667 state.state == lastState &&
668 state.stateDescription != lastStateDescription
669 ) {
670 stateDescriptionDeltas = state.stateDescription
671 }
672 }
673
674 setStateDescription(stateDescription.toString())
675 lastStateDescription = state.stateDescription
676
677 accessibilityClass =
678 if (state.state == Tile.STATE_UNAVAILABLE) {
679 null
680 } else {
681 state.expandedAccessibilityClassName
682 }
683
684 if (state is AdapterState) {
685 val newState = state.value
686 if (tileState != newState) {
687 tileState = newState
688 }
689 }
690
691 // Labels
692 if (!Objects.equals(label.text, state.label)) {
693 label.text = state.label
694 }
695 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
696 secondaryLabel.text = state.secondaryLabel
697 secondaryLabel.visibility =
698 if (TextUtils.isEmpty(state.secondaryLabel)) {
699 GONE
700 } else {
701 VISIBLE
702 }
703 }
704
705 // Colors
706 if (state.state != lastState || state.disabledByPolicy != lastDisabledByPolicy) {
707 singleAnimator.cancel()
708 mQsLogger?.logTileBackgroundColorUpdateIfInternetTile(
709 state.spec,
710 state.state,
711 state.disabledByPolicy,
712 getBackgroundColorForState(state.state, state.disabledByPolicy)
713 )
714 if (allowAnimations) {
715 singleAnimator.setValues(
716 colorValuesHolder(
717 BACKGROUND_NAME,
718 backgroundColor,
719 getBackgroundColorForState(state.state, state.disabledByPolicy)
720 ),
721 colorValuesHolder(
722 LABEL_NAME,
723 label.currentTextColor,
724 getLabelColorForState(state.state, state.disabledByPolicy)
725 ),
726 colorValuesHolder(
727 SECONDARY_LABEL_NAME,
728 secondaryLabel.currentTextColor,
729 getSecondaryLabelColorForState(state.state, state.disabledByPolicy)
730 ),
731 colorValuesHolder(
732 CHEVRON_NAME,
733 chevronView.imageTintList?.defaultColor ?: 0,
734 getChevronColorForState(state.state, state.disabledByPolicy)
735 ),
736 colorValuesHolder(
737 OVERLAY_NAME,
738 backgroundOverlayColor,
739 getOverlayColorForState(state.state)
740 )
741 )
742 singleAnimator.start()
743 } else {
744 setAllColors(
745 getBackgroundColorForState(state.state, state.disabledByPolicy),
746 getLabelColorForState(state.state, state.disabledByPolicy),
747 getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
748 getChevronColorForState(state.state, state.disabledByPolicy),
749 getOverlayColorForState(state.state)
750 )
751 }
752 }
753
754 // Right side icon
755 loadSideViewDrawableIfNecessary(state)
756
757 label.isEnabled = !state.disabledByPolicy
758
759 lastState = state.state
760 lastDisabledByPolicy = state.disabledByPolicy
761 lastIconTint = icon.getColor(state)
762
763 // Long-press effects
764 if (
765 state.handlesLongClick &&
766 longPressEffect?.initializeEffect(longPressEffectDuration) == true
767 ) {
768 showRippleEffect = false
769 initializeLongPressProperties(measuredHeight, measuredWidth)
770 } else {
771 // Long-press effects might have been enabled before but the new state does not
772 // handle a long-press. In this case, we go back to the behaviour of a regular tile
773 // and clean-up the resources
774 showRippleEffect = isClickable
775 initialLongPressProperties = null
776 finalLongPressProperties = null
777 }
778 }
779
780 private fun setAllColors(
781 backgroundColor: Int,
782 labelColor: Int,
783 secondaryLabelColor: Int,
784 chevronColor: Int,
785 overlayColor: Int,
786 ) {
787 setColor(backgroundColor)
788 setLabelColor(labelColor)
789 setSecondaryLabelColor(secondaryLabelColor)
790 setChevronColor(chevronColor)
791 setOverlayColor(overlayColor)
792 }
793
794 private fun setColor(color: Int) {
795 backgroundBaseDrawable.mutate().setTint(color)
796 backgroundColor = color
797 }
798
799 private fun setLabelColor(color: Int) {
800 label.setTextColor(color)
801 }
802
803 private fun setSecondaryLabelColor(color: Int) {
804 secondaryLabel.setTextColor(color)
805 }
806
807 private fun setChevronColor(color: Int) {
808 chevronView.imageTintList = ColorStateList.valueOf(color)
809 }
810
811 private fun setOverlayColor(overlayColor: Int) {
812 backgroundOverlayDrawable.setTint(overlayColor)
813 backgroundOverlayColor = overlayColor
814 }
815
816 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
817 if (state.sideViewCustomDrawable != null) {
818 customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
819 customDrawableView.visibility = VISIBLE
820 chevronView.visibility = GONE
821 } else if (state !is AdapterState || state.forceExpandIcon) {
822 customDrawableView.setImageDrawable(null)
823 customDrawableView.visibility = GONE
824 chevronView.visibility = VISIBLE
825 } else {
826 customDrawableView.setImageDrawable(null)
827 customDrawableView.visibility = GONE
828 chevronView.visibility = GONE
829 }
830 }
831
832 private fun getUnavailableText(spec: String?): String {
833 val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
834 return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
835 }
836
837 /*
838 * The view should not be animated if it's not on screen and no part of it is visible.
839 */
840 protected open fun animationsEnabled(): Boolean {
841 if (!isShown) {
842 return false
843 }
844 if (alpha != 1f) {
845 return false
846 }
847 getLocationOnScreen(locInScreen)
848 return locInScreen.get(1) >= -height
849 }
850
851 private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
852 return when {
853 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable
854 state == Tile.STATE_ACTIVE -> colorActive
855 state == Tile.STATE_INACTIVE -> colorInactive
856 else -> {
857 Log.e(TAG, "Invalid state $state")
858 0
859 }
860 }
861 }
862
863 private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
864 return when {
865 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable
866 state == Tile.STATE_ACTIVE -> colorLabelActive
867 state == Tile.STATE_INACTIVE -> colorLabelInactive
868 else -> {
869 Log.e(TAG, "Invalid state $state")
870 0
871 }
872 }
873 }
874
875 private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
876 return when {
877 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable
878 state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive
879 state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
880 else -> {
881 Log.e(TAG, "Invalid state $state")
882 0
883 }
884 }
885 }
886
887 private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int =
888 getSecondaryLabelColorForState(state, disabledByPolicy)
889
890 private fun getOverlayColorForState(state: Int): Int {
891 return when (state) {
892 Tile.STATE_ACTIVE -> overlayColorActive
893 Tile.STATE_INACTIVE -> overlayColorInactive
894 else -> Color.TRANSPARENT
895 }
896 }
897
898 override fun onActivityLaunchAnimationEnd() {
899 if (longPressEffect != null && !haveLongPressPropertiesBeenReset) {
900 resetLongPressEffectProperties()
901 }
902 }
903
904 fun prepareForLaunch() {
905 val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
906 val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
907 val deltaH = finalLongPressProperties?.height?.minus(startingHeight)?.toInt() ?: 0
908 val deltaW = finalLongPressProperties?.width?.minus(startingWidth)?.toInt() ?: 0
909 paddingForLaunch.left = -deltaW / 2
910 paddingForLaunch.top = -deltaH / 2
911 paddingForLaunch.right = deltaW / 2
912 paddingForLaunch.bottom = deltaH / 2
913 }
914
915 override fun getPaddingForLaunchAnimation(): Rect = paddingForLaunch
916
917 fun updateLongPressEffectProperties(effectProgress: Float) {
918 if (!isLongClickable || longPressEffect == null) return
919
920 if (haveLongPressPropertiesBeenReset) haveLongPressPropertiesBeenReset = false
921
922 // Dimensions change
923 val newHeight =
924 interpolateFloat(
925 effectProgress,
926 initialLongPressProperties?.height ?: 0f,
927 finalLongPressProperties?.height ?: 0f,
928 )
929 .toInt()
930 val newWidth =
931 interpolateFloat(
932 effectProgress,
933 initialLongPressProperties?.width ?: 0f,
934 finalLongPressProperties?.width ?: 0f,
935 )
936 .toInt()
937
938 val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
939 val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
940 val deltaH = (newHeight - startingHeight) / 2
941 val deltaW = (newWidth - startingWidth) / 2
942
943 background.updateBounds(
944 left = -deltaW,
945 top = -deltaH,
946 right = newWidth - deltaW,
947 bottom = newHeight - deltaH,
948 )
949
950 // Radius change
951 val newRadius =
952 interpolateFloat(
953 effectProgress,
954 initialLongPressProperties?.cornerRadius ?: 0f,
955 finalLongPressProperties?.cornerRadius ?: 0f,
956 )
957 changeCornerRadius(newRadius)
958
959 // Color change
960 setAllColors(
961 colorEvaluator.evaluate(
962 effectProgress,
963 initialLongPressProperties?.backgroundColor ?: 0,
964 finalLongPressProperties?.backgroundColor ?: 0,
965 ) as Int,
966 colorEvaluator.evaluate(
967 effectProgress,
968 initialLongPressProperties?.labelColor ?: 0,
969 finalLongPressProperties?.labelColor ?: 0,
970 ) as Int,
971 colorEvaluator.evaluate(
972 effectProgress,
973 initialLongPressProperties?.secondaryLabelColor ?: 0,
974 finalLongPressProperties?.secondaryLabelColor ?: 0,
975 ) as Int,
976 colorEvaluator.evaluate(
977 effectProgress,
978 initialLongPressProperties?.chevronColor ?: 0,
979 finalLongPressProperties?.chevronColor ?: 0,
980 ) as Int,
981 colorEvaluator.evaluate(
982 effectProgress,
983 initialLongPressProperties?.overlayColor ?: 0,
984 finalLongPressProperties?.overlayColor ?: 0,
985 ) as Int,
986 )
987 icon.setTint(
988 icon.mIcon as ImageView,
989 colorEvaluator.evaluate(
990 effectProgress,
991 initialLongPressProperties?.iconColor ?: 0,
992 finalLongPressProperties?.iconColor ?: 0,
993 ) as Int,
994 )
995 }
996
997 private fun interpolateFloat(fraction: Float, start: Float, end: Float): Float =
998 start + fraction * (end - start)
999
1000 fun resetLongPressEffectProperties() {
1001 background.updateBounds(
1002 left = 0,
1003 top = 0,
1004 right = initialLongPressProperties?.width?.toInt() ?: measuredWidth,
1005 bottom = initialLongPressProperties?.height?.toInt() ?: measuredHeight,
1006 )
1007 changeCornerRadius(resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat())
1008 setAllColors(
1009 getBackgroundColorForState(lastState, lastDisabledByPolicy),
1010 getLabelColorForState(lastState, lastDisabledByPolicy),
1011 getSecondaryLabelColorForState(lastState, lastDisabledByPolicy),
1012 getChevronColorForState(lastState, lastDisabledByPolicy),
1013 getOverlayColorForState(lastState),
1014 )
1015 icon.setTint(icon.mIcon as ImageView, lastIconTint)
1016 haveLongPressPropertiesBeenReset = true
1017 }
1018
1019 @VisibleForTesting
1020 fun initializeLongPressProperties(startingHeight: Int, startingWidth: Int) {
1021 initialLongPressProperties =
1022 QSLongPressProperties(
1023 height = startingHeight.toFloat(),
1024 width = startingWidth.toFloat(),
1025 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat(),
1026 getBackgroundColorForState(lastState),
1027 getLabelColorForState(lastState),
1028 getSecondaryLabelColorForState(lastState),
1029 getChevronColorForState(lastState),
1030 getOverlayColorForState(lastState),
1031 lastIconTint,
1032 )
1033
1034 finalLongPressProperties =
1035 QSLongPressProperties(
1036 height = LONG_PRESS_EFFECT_HEIGHT_SCALE * startingHeight,
1037 width = LONG_PRESS_EFFECT_WIDTH_SCALE * startingWidth,
1038 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat() - 20,
1039 getBackgroundColorForState(Tile.STATE_ACTIVE),
1040 getLabelColorForState(Tile.STATE_ACTIVE),
1041 getSecondaryLabelColorForState(Tile.STATE_ACTIVE),
1042 getChevronColorForState(Tile.STATE_ACTIVE),
1043 getOverlayColorForState(Tile.STATE_ACTIVE),
1044 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive),
1045 )
1046 }
1047
1048 private fun changeCornerRadius(radius: Float) {
1049 for (i in 0 until backgroundDrawable.numberOfLayers) {
1050 val layer = backgroundDrawable.getDrawable(i)
1051 if (layer is GradientDrawable) {
1052 layer.cornerRadius = radius
1053 }
1054 }
1055 }
1056
1057 @VisibleForTesting
1058 internal fun getCurrentColors(): List<Int> =
1059 listOf(
1060 backgroundColor,
1061 label.currentTextColor,
1062 secondaryLabel.currentTextColor,
1063 chevronView.imageTintList?.defaultColor ?: 0
1064 )
1065
1066 inner class StateChangeRunnable(private val state: QSTile.State) : Runnable {
1067 override fun run() {
1068 traceSection("QSTileViewImpl#handleStateChanged") { handleStateChanged(state) }
1069 }
1070
1071 // We want all instances of this runnable to be equal to each other, so they can be used to
1072 // remove previous instances from the Handler/RunQueue of this view
1073 override fun equals(other: Any?): Boolean {
1074 return other is StateChangeRunnable
1075 }
1076
1077 // This makes sure that all instances have the same hashcode (because they are `equal`)
1078 override fun hashCode(): Int {
1079 return StateChangeRunnable::class.hashCode()
1080 }
1081 }
1082 }
1083
constrainSquishinessnull1084 fun constrainSquishiness(squish: Float): Float {
1085 return 0.1f + squish * 0.9f
1086 }
1087
colorValuesHoldernull1088 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
1089 return PropertyValuesHolder.ofInt(name, *values).apply {
1090 setEvaluator(ArgbEvaluator.getInstance())
1091 }
1092 }
1093