[go: nahoru, domu]

blob: 3947f6d108702fae7d76182c41da7c295ef7b051 [file] [log] [blame]
Nikolay Igottib3b60792020-07-03 17:03:28 +03001/*
2 * Copyright 2020 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
17package androidx.ui.core
18
19import android.annotation.SuppressLint
20import android.content.Context
21import android.graphics.PixelFormat
Filip Pavlis1ce05e02020-07-17 11:23:18 +010022import android.graphics.Rect
Nikolay Igottib3b60792020-07-03 17:03:28 +030023import android.view.Gravity
24import android.view.MotionEvent
25import android.view.View
26import android.view.WindowManager
27import android.widget.FrameLayout
28import androidx.compose.Composable
29import androidx.compose.Composition
30import androidx.compose.ExperimentalComposeApi
31import androidx.compose.compositionReference
32import androidx.compose.currentComposer
33import androidx.compose.emptyContent
34import androidx.compose.onCommit
35import androidx.compose.onDispose
36import androidx.compose.remember
37import androidx.lifecycle.ViewTreeLifecycleOwner
38import androidx.lifecycle.ViewTreeViewModelStoreOwner
39import androidx.ui.core.semantics.semantics
40import androidx.ui.geometry.Offset
41import androidx.ui.semantics.popup
Mihai Popab7ffbed2020-07-06 13:25:18 +010042import androidx.ui.unit.IntBounds
Nikolay Igottib3b60792020-07-03 17:03:28 +030043import androidx.ui.unit.round
44import org.jetbrains.annotations.TestOnly
45
46/**
47 * Opens a popup with the given content.
48 *
49 * The popup is positioned using a custom [popupPositionProvider].
50 *
51 * @sample androidx.ui.core.samples.PopupSample
52 *
53 * @param popupPositionProvider Provides the screen position of the popup.
54 * @param isFocusable Indicates if the popup can grab the focus.
55 * @param onDismissRequest Executes when the popup tries to dismiss itself. This happens when
56 * the popup is focusable and the user clicks outside.
57 * @param children The content to be displayed inside the popup.
58 */
59@Composable
60internal actual fun ActualPopup(
61 popupPositionProvider: PopupPositionProvider,
62 isFocusable: Boolean,
63 onDismissRequest: (() -> Unit)?,
64 children: @Composable () -> Unit
65) {
66 val view = ViewAmbient.current
67 val providedTestTag = PopupTestTagAmbient.current
68
69 val popupPositionProperties = remember { PopupPositionProperties() }
Filip Pavlis1ce05e02020-07-17 11:23:18 +010070 val popupLayout = remember {
Nikolay Igottib3b60792020-07-03 17:03:28 +030071 PopupLayout(
72 composeView = view,
Nikolay Igottib3b60792020-07-03 17:03:28 +030073 onDismissRequest = onDismissRequest,
Nikolay Igottib3b60792020-07-03 17:03:28 +030074 testTag = providedTestTag
75 )
76 }
Filip Pavlis1ce05e02020-07-17 11:23:18 +010077
78 // Refresh anything that might have changed
79 popupLayout.testTag = providedTestTag
80 remember(isFocusable) { popupLayout.updateLayoutParams(isFocusable) }
Nikolay Igottib3b60792020-07-03 17:03:28 +030081
82 var composition: Composition? = null
83
84 // TODO(soboleva): Look at module arrangement so that Box can be
85 // used instead of this custom Layout
86 // Get the parent's global position, size and layout direction
87 Layout(children = emptyContent(), modifier = Modifier.onPositioned { childCoordinates ->
88 val coordinates = childCoordinates.parentCoordinates!!
89 // Get the global position of the parent
90 val layoutPosition = coordinates.localToGlobal(Offset.Zero).round()
91 val layoutSize = coordinates.size
92
Filip Pavlis1ce05e02020-07-17 11:23:18 +010093 popupPositionProperties.parentGlobalBounds = IntBounds(layoutPosition, layoutSize)
Nikolay Igottib3b60792020-07-03 17:03:28 +030094
95 // Update the popup's position
Filip Pavlis1ce05e02020-07-17 11:23:18 +010096 popupLayout.updatePosition(popupPositionProvider, popupPositionProperties)
Nikolay Igottib3b60792020-07-03 17:03:28 +030097 }) { _, _ ->
Filip Pavlis1ce05e02020-07-17 11:23:18 +010098 popupPositionProperties.parentLayoutDirection = layoutDirection
Nikolay Igottib3b60792020-07-03 17:03:28 +030099 layout(0, 0) {}
100 }
101
102 // TODO(lmr): refactor these APIs so that recomposer isn't necessary
103 @OptIn(ExperimentalComposeApi::class)
104 val recomposer = currentComposer.recomposer
105 val parentComposition = compositionReference()
106 onCommit {
107 composition = popupLayout.setContent(recomposer, parentComposition) {
Alexandre Eliasc60f33e2020-07-10 16:23:09 -0700108 SimpleStack(Modifier.semantics { this.popup() }.onPositioned {
Nikolay Igottib3b60792020-07-03 17:03:28 +0300109 // Get the size of the content
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100110 popupPositionProperties.popupContentSize = it.size
Nikolay Igottib3b60792020-07-03 17:03:28 +0300111
112 // Update the popup's position
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100113 popupLayout.updatePosition(popupPositionProvider, popupPositionProperties)
Nikolay Igottib3b60792020-07-03 17:03:28 +0300114 }, children = children)
115 }
116 }
117
118 onDispose {
119 composition?.dispose()
120 // Remove the window
121 popupLayout.dismiss()
122 }
123}
124
125// TODO(soboleva): Look at module dependencies so that we can get code reuse between
126// Popup's SimpleStack and Stack.
127@Suppress("NOTHING_TO_INLINE")
128@Composable
129private inline fun SimpleStack(modifier: Modifier, noinline children: @Composable () -> Unit) {
130 Layout(children = children, modifier = modifier) { measurables, constraints ->
131 when (measurables.size) {
132 0 -> layout(0, 0) {}
133 1 -> {
134 val p = measurables[0].measure(constraints)
135 layout(p.width, p.height) {
136 p.place(0, 0)
137 }
138 }
139 else -> {
140 val placeables = measurables.map { it.measure(constraints) }
141 var width = 0
142 var height = 0
143 for (i in 0..placeables.lastIndex) {
144 val p = placeables[i]
145 width = maxOf(width, p.width)
146 height = maxOf(height, p.height)
147 }
148 layout(width, height) {
149 for (i in 0..placeables.lastIndex) {
150 val p = placeables[i]
151 p.place(0, 0)
152 }
153 }
154 }
155 }
156 }
157}
158
159/**
160 * The layout the popup uses to display its content.
161 *
162 * @param composeView The parent view of the popup which is the AndroidComposeView.
Nikolay Igottib3b60792020-07-03 17:03:28 +0300163 * @param onDismissRequest Executed when the popup tries to dismiss itself.
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100164 * @param testTag The test tag used to match the popup in tests.
Nikolay Igottib3b60792020-07-03 17:03:28 +0300165 */
166@SuppressLint("ViewConstructor")
167private class PopupLayout(
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100168 private val composeView: View,
169 private val onDismissRequest: (() -> Unit)? = null,
Nikolay Igottib3b60792020-07-03 17:03:28 +0300170 var testTag: String
171) : FrameLayout(composeView.context) {
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100172 private val windowManager =
Nikolay Igottib3b60792020-07-03 17:03:28 +0300173 composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100174 private val params = createLayoutParams()
175 private var viewAdded: Boolean = false
Nikolay Igottib3b60792020-07-03 17:03:28 +0300176
177 init {
178 id = android.R.id.content
Nikolay Igottib3b60792020-07-03 17:03:28 +0300179 ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(composeView))
180 ViewTreeViewModelStoreOwner.set(this, ViewTreeViewModelStoreOwner.get(composeView))
181 }
182
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100183 private fun Rect.toIntBounds() = IntBounds(
184 left = left,
185 top = top,
186 right = right,
187 bottom = bottom
188 )
189
Nikolay Igottib3b60792020-07-03 17:03:28 +0300190 /**
191 * Shows the popup at a position given by the method which calculates the coordinates
192 * relative to its parent.
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100193 *
194 * @param positionProvider The logic of positioning the popup relative to its parent.
195 * @param positionProperties Properties to use to position the popup.
Nikolay Igottib3b60792020-07-03 17:03:28 +0300196 */
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100197 fun updatePosition(
198 positionProvider: PopupPositionProvider,
199 positionProperties: PopupPositionProperties
200 ) {
201 val windowGlobalBounds = Rect().let {
202 composeView.rootView.getWindowVisibleDisplayFrame(it)
203 it.toIntBounds()
204 }
205
206 val popupGlobalPosition = positionProvider.calculatePosition(
207 positionProperties.parentGlobalBounds,
208 windowGlobalBounds,
209 positionProperties.parentLayoutDirection,
210 positionProperties.popupContentSize
Nikolay Igottib3b60792020-07-03 17:03:28 +0300211 )
212
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100213 // WindowManager treats the given coordinates as relative to our window, not relative to the
214 // screen. Which means that we need to translate them. Other option would be to only work
215 // with window relative coordinates but our layout APIs don't provide this value so it
216 // could be confusing for the implementors of position provider.
217 val rootViewLocation = IntArray(2)
218 composeView.rootView.getLocationOnScreen(rootViewLocation)
219 params.x = popupGlobalPosition.x - rootViewLocation[0]
220 params.y = popupGlobalPosition.y - rootViewLocation[1]
Nikolay Igottib3b60792020-07-03 17:03:28 +0300221
222 if (!viewAdded) {
223 windowManager.addView(this, params)
224 viewAdded = true
225 } else {
226 windowManager.updateViewLayout(this, params)
227 }
228 }
229
230 /**
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100231 * Update the LayoutParams.
232 *
233 * @param popupIsFocusable Indicates if the popup can grab the focus.
Nikolay Igottib3b60792020-07-03 17:03:28 +0300234 */
Filip Pavlis1ce05e02020-07-17 11:23:18 +0100235 fun updateLayoutParams(popupIsFocusable: Boolean) {
236 params.flags = if (!popupIsFocusable) {
237 params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
238 } else {
239 params.flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
240 }
241
242 if (viewAdded) {
243 windowManager.updateViewLayout(this, params)
Nikolay Igottib3b60792020-07-03 17:03:28 +0300244 }
245 }
246
247 /**
248 * Remove the view from the [WindowManager].
249 */
250 fun dismiss() {
251 ViewTreeLifecycleOwner.set(this, null)
252 windowManager.removeViewImmediate(this)
253 }
254
255 /**
256 * Handles touch screen motion events and calls [onDismissRequest] when the
257 * users clicks outside the popup.
258 */
259 override fun onTouchEvent(event: MotionEvent?): Boolean {
260 if ((event?.action == MotionEvent.ACTION_DOWN) &&
261 ((event.x < 0) || (event.x >= width) || (event.y < 0) || (event.y >= height))
262 ) {
263 onDismissRequest?.invoke()
264 return true
265 } else if (event?.action == MotionEvent.ACTION_OUTSIDE) {
266 onDismissRequest?.invoke()
267 return true
268 }
269
270 return super.onTouchEvent(event)
271 }
272
273 /**
274 * Initialize the LayoutParams specific to [android.widget.PopupWindow].
275 */
276 private fun createLayoutParams(): WindowManager.LayoutParams {
277 return WindowManager.LayoutParams().apply {
278 // Start to position the popup in the top left corner, a new position will be calculated
279 gravity = Gravity.START or Gravity.TOP
280
281 // Flags specific to android.widget.PopupWindow
282 flags = flags and (WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
283 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
284 WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
285 WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
286 WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
287 WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or
288 WindowManager.LayoutParams.FLAG_SPLIT_TOUCH).inv()
289
290 type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
291
292 // Get the Window token from the parent view
293 token = composeView.applicationWindowToken
294
295 // Wrap the frame layout which contains composable content
296 width = WindowManager.LayoutParams.WRAP_CONTENT
297 height = WindowManager.LayoutParams.WRAP_CONTENT
298
299 format = PixelFormat.TRANSLUCENT
300 }
301 }
302}
303
304/**
305 * Returns whether the given view is an underlying decor view of a popup. If the given testTag is
306 * supplied it also verifies that the popup has such tag assigned.
307 *
308 * @param view View to verify.
309 * @param testTag If provided, tests that the given tag in defined on the popup.
310 */
311// TODO(b/139861182): Move this functionality to ComposeTestRule
312@TestOnly
313fun isPopupLayout(view: View, testTag: String? = null): Boolean =
314 view is PopupLayout && (testTag == null || testTag == view.testTag)