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