[go: nahoru, domu]

blob: f26daf043092dfd39a54b12be8abf1b034442063 [file] [log] [blame]
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.ui.foundation
import android.app.Activity
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.Composable
import androidx.compose.Compose
import androidx.compose.Context
import androidx.compose.Immutable
import androidx.compose.TestOnly
import androidx.compose.ambient
import androidx.compose.disposeComposition
import androidx.compose.escapeCompose
import androidx.compose.memo
import androidx.compose.onCommit
import androidx.compose.onDispose
import androidx.compose.unaryPlus
import androidx.ui.core.Alignment
import androidx.ui.core.AndroidComposeView
import androidx.ui.core.AndroidComposeViewAmbient
import androidx.ui.core.ContextAmbient
import androidx.ui.core.IntPx
import androidx.ui.core.IntPxPosition
import androidx.ui.core.IntPxSize
import androidx.ui.core.OnChildPositioned
import androidx.ui.core.OnPositioned
import androidx.ui.core.PxPosition
import androidx.ui.core.PxSize
import androidx.ui.core.TestTagAmbient
import androidx.ui.core.round
import androidx.ui.core.setContent
/**
* Opens a popup with the given content.
*
* The popup is positioned relative to its parent, using the [alignment] and [offset].
* The popup is visible as long as it is part of the composition hierarchy.
*
* @sample androidx.ui.foundation.samples.PopupSample
*
* @param alignment The alignment relative to the parent.
* @param offset An offset from the original aligned position of the popup.
* @param popupProperties Provides extended set of properties to configure the popup.
* @param children The content to be displayed inside the popup.
*/
@Composable
fun Popup(
alignment: Alignment = Alignment.TopLeft,
offset: IntPxPosition = IntPxPosition(IntPx.Zero, IntPx.Zero),
popupProperties: PopupProperties = PopupProperties(),
children: @Composable() () -> Unit
) {
// Memoize the object, but change the value of the properties if a recomposition happens
val popupPositionProperties = +memo {
PopupPositionProperties(
offset = offset
)
}
popupPositionProperties.offset = offset
Popup(
popupProperties = popupProperties,
popupPositionProperties = popupPositionProperties,
calculatePopupPosition = { calculatePopupGlobalPosition(it, alignment) },
children = children
)
}
/**
* Opens a popup with the given content.
*
* The dropdown popup is positioned below its parent, using the [dropDownAlignment] and [offset].
* The dropdown popup is visible as long as it is part of the composition hierarchy.
*
* @sample androidx.ui.foundation.samples.DropdownPopupSample
*
* @param dropDownAlignment The left or right alignment below the parent.
* @param offset An offset from the original aligned position of the popup.
* @param popupProperties Provides extended set of properties to configure the popup.
* @param children The content to be displayed inside the popup.
*/
@Composable
fun DropdownPopup(
dropDownAlignment: DropDownAlignment = DropDownAlignment.Left,
offset: IntPxPosition = IntPxPosition(IntPx.Zero, IntPx.Zero),
popupProperties: PopupProperties = PopupProperties(),
children: @Composable() () -> Unit
) {
// Memoize the object, but change the value of the properties if a recomposition happens
val popupPositionProperties = +memo {
PopupPositionProperties(
offset = offset
)
}
popupPositionProperties.offset = offset
Popup(
popupProperties = popupProperties,
popupPositionProperties = popupPositionProperties,
calculatePopupPosition = { calculateDropdownPopupPosition(it, dropDownAlignment) },
children = children
)
}
@Composable
private fun Popup(
popupProperties: PopupProperties,
popupPositionProperties: PopupPositionProperties,
calculatePopupPosition: ((PopupPositionProperties) -> IntPxPosition),
children: @Composable() () -> Unit
) {
val context = +ambient(ContextAmbient)
// TODO(b/139866476): Decide if we want to expose the AndroidComposeView
val composeView = +ambient(AndroidComposeViewAmbient)
val providedTestTag = +ambient(TestTagAmbient)
val popupLayout = +memo(popupProperties) {
escapeCompose { PopupLayout(
context = context,
composeView = composeView,
popupProperties = popupProperties,
popupPositionProperties = popupPositionProperties,
calculatePopupPosition = calculatePopupPosition,
testTag = providedTestTag
) }
}
popupLayout.calculatePopupPosition = calculatePopupPosition
// Get the parent's global position and size
OnPositioned { coordinates ->
// Get the global position of the parent
val layoutPosition = coordinates.localToGlobal(PxPosition.Origin)
val layoutSize = coordinates.size
popupLayout.popupPositionProperties.parentPosition = layoutPosition
popupLayout.popupPositionProperties.parentSize = layoutSize
// Update the popup's position
popupLayout.updatePosition()
}
+onCommit {
popupLayout.setContent {
OnChildPositioned({
// Get the size of the content
popupLayout.popupPositionProperties.childrenSize = it.size
// Update the popup's position
popupLayout.updatePosition()
}, children)
}
}
+onDispose {
popupLayout.disposeComposition()
// Remove the window
popupLayout.dismiss()
}
}
/**
* The layout the popup uses to display its content.
*
* @param context The application context.
* @param composeView The parent view of the popup which is the AndroidComposeView.
* @param popupProperties Properties of the popup.
* @param calculatePopupPosition The logic of positioning the popup relative to its parent.
*/
private class PopupLayout(
context: Context,
val composeView: View,
val popupProperties: PopupProperties,
var popupPositionProperties: PopupPositionProperties,
var calculatePopupPosition: ((PopupPositionProperties) -> IntPxPosition),
var testTag: String
) : FrameLayout(context) {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val params = createLayoutParams()
var viewAdded: Boolean = false
init {
updateLayoutParams()
}
/**
* Shows the popup at a position given by the method which calculates the coordinates
* relative to its parent.
*/
fun updatePosition() {
val popupGlobalPosition = calculatePopupPosition(popupPositionProperties)
params.x = popupGlobalPosition.x.value
params.y = popupGlobalPosition.y.value
if (!viewAdded) {
windowManager.addView(this, params)
viewAdded = true
} else {
windowManager.updateViewLayout(this, params)
}
}
/**
* Update the LayoutParams using the popup's properties.
*/
fun updateLayoutParams() {
if (!popupProperties.isFocusable) {
this.params.flags = this.params.flags or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
}
}
/**
* Remove the view from the [WindowManager].
*/
fun dismiss() {
windowManager.removeViewImmediate(this)
}
/**
* Handles touch screen motion events and calls [PopupProperties.onDismissRequest] when the
* users clicks outside the popup.
*/
override fun onTouchEvent(event: MotionEvent?): Boolean {
if ((event?.action == MotionEvent.ACTION_DOWN) &&
((event.x < 0) || (event.x >= width) || (event.y < 0) || (event.y >= height))) {
popupProperties.onDismissRequest?.invoke()
return true
} else if (event?.action == MotionEvent.ACTION_OUTSIDE) {
popupProperties.onDismissRequest?.invoke()
return true
}
return super.onTouchEvent(event)
}
/**
* Initialize the LayoutParams specific to [android.widget.PopupWindow].
*/
private fun createLayoutParams(): WindowManager.LayoutParams {
return WindowManager.LayoutParams().apply {
// Start to position the popup in the top left corner, a new position will be calculated
gravity = Gravity.START or Gravity.TOP
// Flags specific to android.widget.PopupWindow
flags = flags and (WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or
WindowManager.LayoutParams.FLAG_SPLIT_TOUCH).inv()
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Wrap the frame layout which contains composable content
width = WindowManager.LayoutParams.WRAP_CONTENT
height = WindowManager.LayoutParams.WRAP_CONTENT
format = PixelFormat.TRANSLUCENT
}
}
}
// TODO(b/139800142): Add other PopupWindow properties which may be needed
@Immutable
data class PopupProperties(
/**
* Indicates if the popup can grab the focus.
*/
val isFocusable: Boolean = false,
/**
* Executes when the popup tries to dismiss itself.
* This happens when the popup is focusable and the user clicks outside.
*/
val onDismissRequest: (() -> Unit)? = null
)
internal data class PopupPositionProperties(
var offset: IntPxPosition
) {
var parentPosition = PxPosition.Origin
var parentSize = PxSize.Zero
var childrenSize = PxSize.Zero
}
/**
* The [DropdownPopup] is aligned below its parent relative to its left or right corner.
* [DropDownAlignment] is used to specify how should [DropdownPopup] be aligned.
*/
enum class DropDownAlignment {
Left,
Right
}
internal fun calculatePopupGlobalPosition(
popupPositionProperties: PopupPositionProperties,
alignment: Alignment
): IntPxPosition {
// TODO: Decide which is the best way to round to result without reimplementing Alignment.align
var popupGlobalPosition = IntPxPosition(IntPx.Zero, IntPx.Zero)
// Get the aligned point inside the parent
val parentAlignmentPoint = alignment.align(
IntPxSize(
popupPositionProperties.parentSize.width.round(),
popupPositionProperties.parentSize.height.round()
)
)
// Get the aligned point inside the child
val relativePopupPos = alignment.align(
IntPxSize(
popupPositionProperties.childrenSize.width.round(),
popupPositionProperties.childrenSize.height.round()
)
)
// Add the global position of the parent
popupGlobalPosition += IntPxPosition(
popupPositionProperties.parentPosition.x.round(),
popupPositionProperties.parentPosition.y.round()
)
// Add the distance between the parent's top left corner and the alignment point
popupGlobalPosition += parentAlignmentPoint
// Subtract the distance between the children's top left corner and the alignment point
popupGlobalPosition -= IntPxPosition(relativePopupPos.x, relativePopupPos.y)
// Add the user offset
popupGlobalPosition += popupPositionProperties.offset
return popupGlobalPosition
}
internal fun calculateDropdownPopupPosition(
popupPositionProperties: PopupPositionProperties,
dropDownAlignment: DropDownAlignment
): IntPxPosition {
var popupGlobalPosition = IntPxPosition(IntPx.Zero, IntPx.Zero)
// Add the global position of the parent
popupGlobalPosition += IntPxPosition(
popupPositionProperties.parentPosition.x.round(),
popupPositionProperties.parentPosition.y.round()
)
// The X coordinate of the popup relative to the parent is equal to the parent's width if
// aligned to the END or it is 0 otherwise
val alignmentPositionX =
if (dropDownAlignment == DropDownAlignment.Right) {
popupPositionProperties.parentSize.width.round()
} else {
IntPx.Zero
}
// The popup's position relative to the parent's top left corner
val dropdownAlignmentPosition = IntPxPosition(
alignmentPositionX,
popupPositionProperties.parentSize.height.round()
)
popupGlobalPosition += dropdownAlignmentPosition
// Add the user offset
popupGlobalPosition += popupPositionProperties.offset
return popupGlobalPosition
}
// TODO(b/140396932): Remove once Activity.disposeComposition() is working properly
/**
* Disposes the root view of the Activity.
*/
fun disposeActivityComposition(activity: Activity) {
val composeView = activity.window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? AndroidComposeView
?: error("No root view found")
Compose.disposeComposition(composeView.root, activity, null)
}
/**
* Returns whether the given view is an underlying decor view of a popup. If the given testTag is
* supplied it also verifies that the popup has such tag assigned.
*
* @param view View to verify.
* @param testTag If provided, tests that the given tag in defined on the popup.
*/
// TODO(b/139861182): Move this functionality to ComposeTestRule
@TestOnly
fun isPopupLayout(view: View, testTag: String? = null): Boolean =
view is PopupLayout && (testTag == null || testTag == view.testTag)