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