1 /*
<lambda>null2  * 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.wm.shell.shared.bubbles
18 
19 import android.graphics.Point
20 import android.graphics.RectF
21 import android.view.View
22 import androidx.annotation.VisibleForTesting
23 import androidx.core.animation.Animator
24 import androidx.core.animation.AnimatorListenerAdapter
25 import androidx.core.animation.ObjectAnimator
26 import com.android.wm.shell.shared.bubbles.BaseBubblePinController.LocationChangeListener
27 import com.android.wm.shell.shared.bubbles.BubbleBarLocation.LEFT
28 import com.android.wm.shell.shared.bubbles.BubbleBarLocation.RIGHT
29 
30 /**
31  * Base class for common logic shared between different bubble views to support pinning bubble bar
32  * to left or right edge of screen.
33  *
34  * Handles drag events and allows a [LocationChangeListener] to be registered that is notified when
35  * location of the bubble bar should change.
36  *
37  * Shows a drop target when releasing a view would update the [BubbleBarLocation].
38  */
39 abstract class BaseBubblePinController(private val screenSizeProvider: () -> Point) {
40 
41     private var initialLocationOnLeft = false
42     private var onLeft = false
43     private var dismissZone: RectF? = null
44     private var stuckToDismissTarget = false
45     private var screenCenterX = 0
46     private var listener: LocationChangeListener? = null
47     private var dropTargetAnimator: ObjectAnimator? = null
48 
49     /**
50      * Signal the controller that dragging interaction has started.
51      *
52      * @param initialLocationOnLeft side of the screen where bubble bar is pinned to
53      */
54     fun onDragStart(initialLocationOnLeft: Boolean) {
55         this.initialLocationOnLeft = initialLocationOnLeft
56         onLeft = initialLocationOnLeft
57         screenCenterX = screenSizeProvider.invoke().x / 2
58         dismissZone = getExclusionRect()
59         listener?.onStart(if (initialLocationOnLeft) LEFT else RIGHT)
60     }
61 
62     /** View has moved to [x] and [y] screen coordinates */
63     fun onDragUpdate(x: Float, y: Float) {
64         if (dismissZone?.contains(x, y) == true) return
65 
66         val wasOnLeft = onLeft
67         onLeft = x < screenCenterX
68         if (wasOnLeft != onLeft) {
69             onLocationChange(if (onLeft) LEFT else RIGHT)
70         } else if (stuckToDismissTarget) {
71             // Moved out of the dismiss view back to initial side, if we have a drop target, show it
72             getDropTargetView()?.apply { animateIn() }
73         }
74         // Make sure this gets cleared
75         stuckToDismissTarget = false
76     }
77 
78     /** Signal the controller that view has been dragged to dismiss view. */
79     fun onStuckToDismissTarget() {
80         stuckToDismissTarget = true
81         // Notify that location may be reset
82         val shouldResetLocation = onLeft != initialLocationOnLeft
83         if (shouldResetLocation) {
84             onLeft = initialLocationOnLeft
85             listener?.onChange(if (onLeft) LEFT else RIGHT)
86         }
87         getDropTargetView()?.apply {
88             animateOut {
89                 if (shouldResetLocation) {
90                     updateLocation(if (onLeft) LEFT else RIGHT)
91                 }
92             }
93         }
94     }
95 
96     /** Signal the controller that dragging interaction has finished. */
97     fun onDragEnd() {
98         getDropTargetView()?.let { view -> view.animateOut { removeDropTargetView(view) } }
99         dismissZone = null
100         listener?.onRelease(if (onLeft) LEFT else RIGHT)
101     }
102 
103     /**
104      * [LocationChangeListener] that is notified when dragging interaction has resulted in bubble
105      * bar to be pinned on the other edge
106      */
107     fun setListener(listener: LocationChangeListener?) {
108         this.listener = listener
109     }
110 
111     /** Get width for exclusion rect where dismiss takes over drag */
112     protected abstract fun getExclusionRectWidth(): Float
113 
114     /** Get height for exclusion rect where dismiss takes over drag */
115     protected abstract fun getExclusionRectHeight(): Float
116 
117     /** Create the drop target view and attach it to the parent */
118     protected abstract fun createDropTargetView(): View
119 
120     /** Get the drop target view if it exists */
121     protected abstract fun getDropTargetView(): View?
122 
123     /** Remove the drop target view */
124     protected abstract fun removeDropTargetView(view: View)
125 
126     /** Update size and location of the drop target view */
127     protected abstract fun updateLocation(location: BubbleBarLocation)
128 
129     private fun onLocationChange(location: BubbleBarLocation) {
130         showDropTarget(location)
131         listener?.onChange(location)
132     }
133 
134     private fun getExclusionRect(): RectF {
135         val rect = RectF(0f, 0f, getExclusionRectWidth(), getExclusionRectHeight())
136         // Center it around the bottom center of the screen
137         val screenBottom = screenSizeProvider.invoke().y
138         rect.offsetTo(screenCenterX - rect.width() / 2, screenBottom - rect.height())
139         return rect
140     }
141 
142     private fun showDropTarget(location: BubbleBarLocation) {
143         val targetView = getDropTargetView() ?: createDropTargetView().apply { alpha = 0f }
144         if (targetView.alpha > 0) {
145             targetView.animateOut {
146                 updateLocation(location)
147                 targetView.animateIn()
148             }
149         } else {
150             updateLocation(location)
151             targetView.animateIn()
152         }
153     }
154 
155     private fun View.animateIn() {
156         dropTargetAnimator?.cancel()
157         dropTargetAnimator =
158             ObjectAnimator.ofFloat(this, View.ALPHA, 1f)
159                 .setDuration(DROP_TARGET_ALPHA_IN_DURATION)
160                 .addEndAction { dropTargetAnimator = null }
161         dropTargetAnimator?.start()
162     }
163 
164     private fun View.animateOut(endAction: Runnable? = null) {
165         dropTargetAnimator?.cancel()
166         dropTargetAnimator =
167             ObjectAnimator.ofFloat(this, View.ALPHA, 0f)
168                 .setDuration(DROP_TARGET_ALPHA_OUT_DURATION)
169                 .addEndAction {
170                     endAction?.run()
171                     dropTargetAnimator = null
172                 }
173         dropTargetAnimator?.start()
174     }
175 
176     private fun <T : Animator> T.addEndAction(runnable: Runnable): T {
177         addListener(
178             object : AnimatorListenerAdapter() {
179                 override fun onAnimationEnd(animation: Animator) {
180                     runnable.run()
181                 }
182             }
183         )
184         return this
185     }
186 
187     /** Receive updates on location changes */
188     interface LocationChangeListener {
189         /** Bubble bar dragging has started. Includes the initial location of the bar */
190         fun onStart(location: BubbleBarLocation) {}
191 
192         /**
193          * Bubble bar has been dragged to a new [BubbleBarLocation]. And the drag is still in
194          * progress.
195          *
196          * Triggered when drag gesture passes the middle of the screen and before touch up. Can be
197          * triggered multiple times per gesture.
198          *
199          * @param location new location as a result of the ongoing drag operation
200          */
201         fun onChange(location: BubbleBarLocation) {}
202 
203         /**
204          * Bubble bar has been released in the [BubbleBarLocation].
205          *
206          * @param location final location of the bubble bar once drag is released
207          */
208         fun onRelease(location: BubbleBarLocation)
209     }
210 
211     companion object {
212         @VisibleForTesting const val DROP_TARGET_ALPHA_IN_DURATION = 150L
213         @VisibleForTesting const val DROP_TARGET_ALPHA_OUT_DURATION = 100L
214     }
215 }
216