1 /* 2 * Copyright (C) 2024 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.wallpaper.picker.preview.ui.view 18 19 import android.animation.ArgbEvaluator 20 import android.content.Context 21 import android.graphics.Rect 22 import android.os.Bundle 23 import android.util.AttributeSet 24 import android.view.MotionEvent 25 import android.view.View 26 import android.view.ViewConfiguration 27 import android.widget.FrameLayout 28 import android.widget.TextView 29 import androidx.constraintlayout.motion.widget.MotionLayout 30 import androidx.constraintlayout.motion.widget.MotionLayout.TransitionListener 31 import androidx.core.content.ContextCompat 32 import androidx.core.view.AccessibilityDelegateCompat 33 import androidx.core.view.ViewCompat 34 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat 35 import com.android.wallpaper.R 36 import kotlin.math.pow 37 import kotlin.math.sqrt 38 39 class PreviewTabs( 40 context: Context, 41 attrs: AttributeSet?, 42 ) : 43 FrameLayout( 44 context, 45 attrs, 46 ) { 47 48 private val argbEvaluator = ArgbEvaluator() 49 private val selectedTextColor = ContextCompat.getColor(context, R.color.system_on_primary) 50 private val unSelectedTextColor = ContextCompat.getColor(context, R.color.system_secondary) 51 52 private val motionLayout: MotionLayout 53 private val primaryTabText: TextView 54 private val secondaryTabText: TextView 55 56 private var downX = 0f 57 private var downY = 0f 58 private var onTabSelected: ((index: Int) -> Unit)? = null 59 60 init { 61 inflate(context, R.layout.preview_tabs, this) 62 motionLayout = requireViewById(R.id.preview_tabs) 63 primaryTabText = requireViewById(R.id.primary_tab_text) 64 secondaryTabText = requireViewById(R.id.secondary_tab_text) 65 66 setCustomAccessibilityDelegate() 67 68 motionLayout.setTransitionListener( 69 object : TransitionListener { onTransitionStartednull70 override fun onTransitionStarted( 71 motionLayout: MotionLayout?, 72 startId: Int, 73 endId: Int 74 ) { 75 // Do nothing intended 76 } 77 onTransitionChangenull78 override fun onTransitionChange( 79 motionLayout: MotionLayout?, 80 startId: Int, 81 endId: Int, 82 progress: Float 83 ) { 84 updateTabText(progress) 85 } 86 onTransitionCompletednull87 override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { 88 if (currentId == R.id.primary_tab_selected) { 89 updateTabText(0.0f) 90 primaryTabText.isSelected = true 91 secondaryTabText.isSelected = false 92 onTabSelected?.invoke(0) 93 } else if (currentId == R.id.secondary_tab_selected) { 94 updateTabText(1.0f) 95 primaryTabText.isSelected = false 96 secondaryTabText.isSelected = true 97 onTabSelected?.invoke(1) 98 } 99 } 100 onTransitionTriggernull101 override fun onTransitionTrigger( 102 motionLayout: MotionLayout?, 103 triggerId: Int, 104 positive: Boolean, 105 progress: Float 106 ) { 107 // Do nothing intended 108 } 109 } 110 ) 111 } 112 onInterceptTouchEventnull113 override fun onInterceptTouchEvent(event: MotionEvent): Boolean { 114 if (event.actionMasked == MotionEvent.ACTION_DOWN) { 115 downX = event.rawX 116 downY = event.rawY 117 } 118 119 // We have to use this method to manually intercept a click event, rather than setting the 120 // onClickListener to the individual tabs. This is because, when setting the onClickListener 121 // to the individual tabs, the swipe gesture of the tabs will be overridden. 122 if (isClick(event, downX, downY)) { 123 val primaryTabRect = requireViewById<FrameLayout>(R.id.primary_tab).getViewRect() 124 val secondaryTabRect = requireViewById<FrameLayout>(R.id.secondary_tab).getViewRect() 125 if (primaryTabRect.contains(downX.toInt(), downY.toInt())) { 126 onTabSelected?.invoke(0) 127 return true 128 } else if (secondaryTabRect.contains(downX.toInt(), downY.toInt())) { 129 onTabSelected?.invoke(1) 130 return true 131 } 132 } 133 return super.onInterceptTouchEvent(event) 134 } 135 setOnTabSelectednull136 fun setOnTabSelected(onTabSelected: ((index: Int) -> Unit)) { 137 this.onTabSelected = onTabSelected 138 } 139 140 /** Transition to tab with [TRANSITION_DURATION] transition duration. */ transitionToTabnull141 fun transitionToTab(index: Int) { 142 if (index == 0) { 143 if (motionLayout.currentState != R.id.primary_tab_selected) { 144 motionLayout.setTransitionDuration(TRANSITION_DURATION) 145 motionLayout.transitionToStart() 146 } 147 } else if (index == 1) { 148 if (motionLayout.currentState != R.id.secondary_tab_selected) { 149 motionLayout.setTransitionDuration(TRANSITION_DURATION) 150 motionLayout.transitionToEnd() 151 } 152 } 153 } 154 resetTransitionnull155 fun resetTransition(targetIndex: Int) { 156 motionLayout.setTransition(R.id.primary_tab_selected, R.id.secondary_tab_selected) 157 motionLayout.setProgress(targetIndex.toFloat()) 158 } 159 160 /** Set tab with 0 transition duration. */ setTabnull161 fun setTab(index: Int) { 162 if (index == 0) { 163 updateTabText(0.0f) 164 if (motionLayout.currentState != R.id.primary_tab_selected) { 165 motionLayout.setTransitionDuration(0) 166 motionLayout.transitionToStart() 167 } 168 } else if (index == 1) { 169 updateTabText(1.0f) 170 if (motionLayout.currentState != R.id.secondary_tab_selected) { 171 motionLayout.setTransitionDuration(0) 172 motionLayout.transitionToEnd() 173 } 174 } 175 } 176 updateTabTextnull177 private fun updateTabText(progress: Float) { 178 primaryTabText.apply { 179 setTextColor( 180 argbEvaluator.evaluate(progress, selectedTextColor, unSelectedTextColor) as Int 181 ) 182 background.alpha = (255 * (1 - progress)).toInt() 183 } 184 secondaryTabText.apply { 185 setTextColor( 186 argbEvaluator.evaluate(progress, unSelectedTextColor, selectedTextColor) as Int 187 ) 188 background.alpha = (255 * progress).toInt() 189 } 190 } 191 setTabsTextnull192 fun setTabsText(primaryText: String, secondaryText: String) { 193 primaryTabText.text = primaryText 194 secondaryTabText.text = secondaryText 195 } 196 setCustomAccessibilityDelegatenull197 private fun setCustomAccessibilityDelegate() { 198 ViewCompat.setAccessibilityDelegate( 199 primaryTabText, 200 object : AccessibilityDelegateCompat() { 201 override fun onInitializeAccessibilityNodeInfo( 202 host: View, 203 info: AccessibilityNodeInfoCompat 204 ) { 205 super.onInitializeAccessibilityNodeInfo(host, info) 206 info.addAction( 207 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK 208 ) 209 } 210 211 override fun performAccessibilityAction( 212 host: View, 213 action: Int, 214 args: Bundle? 215 ): Boolean { 216 if ( 217 action == 218 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK.id 219 ) { 220 onTabSelected?.invoke(0) 221 return true 222 } 223 return super.performAccessibilityAction(host, action, args) 224 } 225 } 226 ) 227 228 ViewCompat.setAccessibilityDelegate( 229 secondaryTabText, 230 object : AccessibilityDelegateCompat() { 231 override fun onInitializeAccessibilityNodeInfo( 232 host: View, 233 info: AccessibilityNodeInfoCompat 234 ) { 235 super.onInitializeAccessibilityNodeInfo(host, info) 236 info.addAction( 237 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK 238 ) 239 } 240 241 override fun performAccessibilityAction( 242 host: View, 243 action: Int, 244 args: Bundle? 245 ): Boolean { 246 if ( 247 action == 248 AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK.id 249 ) { 250 onTabSelected?.invoke(1) 251 return true 252 } 253 return super.performAccessibilityAction(host, action, args) 254 } 255 } 256 ) 257 } 258 259 companion object { 260 261 const val TRANSITION_DURATION = 200 262 isClicknull263 private fun isClick(event: MotionEvent, downX: Float, downY: Float): Boolean { 264 return when { 265 // It's not a click if the event is not an UP action (though it may become one 266 // later, when/if an UP is received). 267 event.action != MotionEvent.ACTION_UP -> false 268 // It's not a click if too much time has passed between the down and the current 269 // event. 270 gestureElapsedTime(event) > ViewConfiguration.getTapTimeout() -> false 271 // It's not a click if the touch traveled too far. 272 distanceMoved(event, downX, downY) > ViewConfiguration.getTouchSlop() -> false 273 // Otherwise, this is a click! 274 else -> true 275 } 276 } 277 278 /** 279 * Returns the distance that the pointer traveled in the touch gesture the given event is 280 * part of. 281 */ distanceMovednull282 private fun distanceMoved(event: MotionEvent, downX: Float, downY: Float): Float { 283 val deltaX = event.rawX - downX 284 val deltaY = event.rawY - downY 285 return sqrt(deltaX.pow(2) + deltaY.pow(2)) 286 } 287 288 /** 289 * Returns the elapsed time since the touch gesture the given event is part of has begun. 290 */ gestureElapsedTimenull291 private fun gestureElapsedTime(event: MotionEvent): Long { 292 return event.eventTime - event.downTime 293 } 294 Viewnull295 private fun View.getViewRect(): Rect { 296 val returnRect = Rect() 297 this.getGlobalVisibleRect(returnRect) 298 return returnRect 299 } 300 } 301 } 302