390 lines
17 KiB
Kotlin
390 lines
17 KiB
Kotlin
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
package org.mozilla.fenix.compose.cfr
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Context
|
|
import android.graphics.PixelFormat
|
|
import android.view.View
|
|
import android.view.WindowManager
|
|
import androidx.annotation.Px
|
|
import androidx.annotation.VisibleForTesting
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.platform.AbstractComposeView
|
|
import androidx.compose.ui.platform.LocalConfiguration
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.platform.ViewRootForInspector
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.IntOffset
|
|
import androidx.compose.ui.unit.IntRect
|
|
import androidx.compose.ui.unit.IntSize
|
|
import androidx.compose.ui.unit.LayoutDirection
|
|
import androidx.compose.ui.unit.LayoutDirection.Ltr
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.Popup
|
|
import androidx.compose.ui.window.PopupPositionProvider
|
|
import androidx.compose.ui.window.PopupProperties
|
|
import androidx.core.view.ViewCompat
|
|
import androidx.core.view.WindowInsetsCompat
|
|
import androidx.core.view.marginStart
|
|
import androidx.lifecycle.ViewTreeLifecycleOwner
|
|
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
|
import mozilla.components.support.ktx.android.util.dpToPx
|
|
import org.mozilla.fenix.compose.cfr.CFRPopup.IndicatorDirection.DOWN
|
|
import org.mozilla.fenix.compose.cfr.CFRPopup.IndicatorDirection.UP
|
|
import org.mozilla.fenix.compose.cfr.CFRPopup.PopupAlignment.BODY_TO_ANCHOR_CENTER
|
|
import org.mozilla.fenix.compose.cfr.CFRPopup.PopupAlignment.BODY_TO_ANCHOR_START
|
|
import org.mozilla.fenix.compose.cfr.CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR
|
|
import org.mozilla.fenix.theme.FirefoxTheme
|
|
import org.mozilla.gecko.GeckoScreenOrientation
|
|
import kotlin.math.roundToInt
|
|
|
|
/**
|
|
* Value class allowing to easily reason about what an `Int` represents.
|
|
* This is compiled to the underlying `Int` type so incurs no performance penalty.
|
|
*/
|
|
@JvmInline
|
|
private value class Pixels(val value: Int)
|
|
|
|
/**
|
|
* Simple wrapper over the absolute x-coordinates of the popup. Includes any paddings.
|
|
*/
|
|
private data class PopupHorizontalBounds(
|
|
val startCoord: Pixels,
|
|
val endCoord: Pixels
|
|
)
|
|
|
|
/**
|
|
* [AbstractComposeView] that can be added or removed dynamically in the current window to display
|
|
* a [Composable] based popup anywhere on the screen.
|
|
*
|
|
* @param text [String] shown as the popup content.
|
|
* @param anchor [View] that will serve as the anchor of the popup and serve as lifecycle owner
|
|
* for this popup also.
|
|
* @param properties [CFRPopupProperties] allowing to customize the popup behavior.
|
|
* @param onDismiss Callback for when the popup is dismissed indicating also if the dismissal
|
|
* was explicit - by tapping the "X" button or not.
|
|
* @param action Optional other composable to show just below the popup text.
|
|
*/
|
|
@SuppressLint("ViewConstructor") // Intended to be used only in code, don't need a View constructor
|
|
internal class CFRPopupFullscreenLayout(
|
|
private val text: String,
|
|
private val anchor: View,
|
|
private val properties: CFRPopupProperties,
|
|
private val onDismiss: (Boolean) -> Unit,
|
|
private val action: @Composable (() -> Unit) = {}
|
|
) : AbstractComposeView(anchor.context), ViewRootForInspector {
|
|
private val windowManager = anchor.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
|
|
|
/**
|
|
* Listener for when the anchor is removed from the screen.
|
|
* Useful in the following situations:
|
|
* - lack of purpose - if there is no anchor the context/action to which this popup refers to disappeared
|
|
* - leak from WindowManager - if removing the app from task manager while the popup is shown.
|
|
*
|
|
* Will not inform client about this since the user did not expressly dismissed this popup.
|
|
*/
|
|
private val anchorDetachedListener = OnViewDetachedListener {
|
|
dismiss()
|
|
}
|
|
|
|
/**
|
|
* When the screen is rotated the popup may get improperly anchored
|
|
* because of the async nature of insets and screen rotation.
|
|
* To avoid any improper anchorage the popups are automatically dismissed.
|
|
*
|
|
* Will not inform client about this since the user did not expressly dismissed this popup.
|
|
*/
|
|
private val orientationChangeListener = GeckoScreenOrientation.OrientationChangeListener {
|
|
dismiss()
|
|
}
|
|
|
|
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
|
|
private set
|
|
|
|
init {
|
|
ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(anchor))
|
|
this.setViewTreeSavedStateRegistryOwner(anchor.findViewTreeSavedStateRegistryOwner())
|
|
GeckoScreenOrientation.getInstance().addListener(orientationChangeListener)
|
|
anchor.addOnAttachStateChangeListener(anchorDetachedListener)
|
|
}
|
|
|
|
/**
|
|
* Add a new CFR popup to the current window overlaying everything already displayed.
|
|
* This popup will be dismissed when the user clicks on the "x" button or based on other user actions
|
|
* with such behavior set in [CFRPopupProperties].
|
|
*/
|
|
fun show() {
|
|
windowManager.addView(this, createLayoutParams())
|
|
}
|
|
|
|
@Composable
|
|
override fun Content() {
|
|
val anchorLocation = IntArray(2).apply {
|
|
anchor.getLocationOnScreen(this)
|
|
}
|
|
|
|
val indicatorArrowHeight = Pixels(
|
|
CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp.toPx()
|
|
)
|
|
|
|
val popupBounds = computePopupHorizontalBounds(
|
|
arrowIndicatorWidth = Pixels(CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)),
|
|
)
|
|
|
|
val indicatorOffset = computeIndicatorArrowStartCoord(
|
|
anchorMiddleXCoord = Pixels(anchorLocation.first() + anchor.width / 2),
|
|
popupStartCoord = popupBounds.startCoord,
|
|
arrowIndicatorWidth = Pixels(
|
|
CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)
|
|
)
|
|
)
|
|
|
|
FirefoxTheme {
|
|
Popup(
|
|
popupPositionProvider = getPopupPositionProvider(
|
|
anchorLocation = anchorLocation,
|
|
popupBounds = popupBounds,
|
|
),
|
|
properties = PopupProperties(
|
|
focusable = properties.dismissOnBackPress,
|
|
dismissOnBackPress = properties.dismissOnBackPress,
|
|
dismissOnClickOutside = properties.dismissOnClickOutside,
|
|
),
|
|
onDismissRequest = {
|
|
// For when tapping outside the popup.
|
|
dismiss()
|
|
onDismiss(false)
|
|
}
|
|
) {
|
|
CFRPopupContent(
|
|
text = text,
|
|
indicatorDirection = properties.indicatorDirection,
|
|
indicatorArrowStartOffset = with(LocalDensity.current) {
|
|
indicatorOffset.value.toDp()
|
|
},
|
|
onDismiss = {
|
|
// For when tapping the "X" button.
|
|
dismiss()
|
|
onDismiss(true)
|
|
},
|
|
action = action,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun getPopupPositionProvider(
|
|
anchorLocation: IntArray,
|
|
popupBounds: PopupHorizontalBounds,
|
|
): PopupPositionProvider {
|
|
return object : PopupPositionProvider {
|
|
override fun calculatePosition(
|
|
anchorBounds: IntRect,
|
|
windowSize: IntSize,
|
|
layoutDirection: LayoutDirection,
|
|
popupContentSize: IntSize
|
|
): IntOffset {
|
|
// Popup will be anchored such that the indicator arrow will point to the middle of the anchor View
|
|
// but the popup is allowed some space as start padding in which it can be displayed such that the
|
|
// indicator arrow is exactly at the top-start/bottom-start corner but slightly translated to end.
|
|
// Values are in pixels.
|
|
return IntOffset(
|
|
when (layoutDirection) {
|
|
Ltr -> popupBounds.startCoord.value
|
|
else -> popupBounds.endCoord.value
|
|
},
|
|
when (properties.indicatorDirection) {
|
|
UP -> {
|
|
when (properties.overlapAnchor) {
|
|
true -> anchorLocation.last() + anchor.height / 2
|
|
else -> anchorLocation.last() + anchor.height + properties.popupVerticalOffset.toPx()
|
|
}
|
|
}
|
|
DOWN -> {
|
|
when (properties.overlapAnchor) {
|
|
true -> anchorLocation.last() - popupContentSize.height + anchor.height / 2
|
|
else -> anchorLocation.last() - popupContentSize.height -
|
|
properties.popupVerticalOffset.toPx()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute the x-coordinates for the absolute start and end position of the popup, including any padding.
|
|
* This assumes anchoring is indicated with an arrow to the horizontal middle of the anchor with the popup's
|
|
* body potentially extending to the `start` of the arrow indicator.
|
|
*
|
|
* @param arrowIndicatorWidth x-distance the arrow indicator occupies.
|
|
*/
|
|
@Composable
|
|
private fun computePopupHorizontalBounds(
|
|
arrowIndicatorWidth: Pixels
|
|
): PopupHorizontalBounds {
|
|
val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2
|
|
|
|
return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) {
|
|
val startCoord = when (properties.popupAlignment) {
|
|
BODY_TO_ANCHOR_START -> {
|
|
val visibleAnchorStart = anchor.x.roundToInt() + anchor.paddingStart + anchor.marginStart
|
|
Pixels(visibleAnchorStart - CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx())
|
|
}
|
|
BODY_TO_ANCHOR_CENTER -> {
|
|
val popupWidth = (properties.popupWidth + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp * 2).toPx()
|
|
Pixels(((anchor.x.roundToInt() + anchor.width) - popupWidth) / 2)
|
|
}
|
|
INDICATOR_CENTERED_IN_ANCHOR -> {
|
|
val anchorMiddleXCoord = Pixels(anchor.x.roundToInt() + anchor.width / 2)
|
|
// Push the popup as far to the start as needed including any needed paddings.
|
|
Pixels(
|
|
(anchorMiddleXCoord.value - arrowIndicatorHalfWidth)
|
|
.minus(properties.indicatorArrowStartOffset.toPx())
|
|
.minus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx())
|
|
.coerceAtLeast(getLeftInsets())
|
|
)
|
|
}
|
|
}
|
|
|
|
PopupHorizontalBounds(
|
|
startCoord = startCoord,
|
|
endCoord = Pixels(
|
|
startCoord.value
|
|
.plus(properties.popupWidth.toPx())
|
|
.plus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() * 2)
|
|
)
|
|
)
|
|
} else {
|
|
val startCoord = when (properties.popupAlignment) {
|
|
BODY_TO_ANCHOR_START -> {
|
|
val visibleAnchorEnd = anchor.x.roundToInt() + anchor.width - anchor.paddingStart
|
|
Pixels(visibleAnchorEnd + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx())
|
|
}
|
|
BODY_TO_ANCHOR_CENTER -> {
|
|
val popupWidth = (properties.popupWidth + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp * 2).toPx()
|
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp.toPx()
|
|
Pixels(screenWidth - ((anchor.x.roundToInt() + anchor.width) - popupWidth) / 2)
|
|
}
|
|
INDICATOR_CENTERED_IN_ANCHOR -> {
|
|
val anchorMiddleXCoord = Pixels(anchor.x.roundToInt() + anchor.width / 2)
|
|
Pixels(
|
|
// Push the popup as far to the start (in RTL) as possible.
|
|
anchorMiddleXCoord.value
|
|
.plus(arrowIndicatorHalfWidth)
|
|
.plus(properties.indicatorArrowStartOffset.toPx())
|
|
.plus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx())
|
|
.coerceAtMost(LocalConfiguration.current.screenWidthDp.dp.toPx())
|
|
.plus(getLeftInsets())
|
|
)
|
|
}
|
|
}
|
|
|
|
PopupHorizontalBounds(
|
|
startCoord = startCoord,
|
|
endCoord = Pixels(
|
|
startCoord.value
|
|
.minus(properties.popupWidth.toPx())
|
|
.minus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() * 2)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute the x-coordinate for where the popup's indicator arrow should start
|
|
* relative to the available distance between it and the popup's starting x-coordinate.
|
|
*
|
|
* @param anchorMiddleXCoord x-coordinate for the middle of the anchor.
|
|
* @param popupStartCoord x-coordinate for the popup start
|
|
* @param arrowIndicatorWidth Width of the arrow indicator.
|
|
*/
|
|
@Composable
|
|
private fun computeIndicatorArrowStartCoord(
|
|
anchorMiddleXCoord: Pixels,
|
|
popupStartCoord: Pixels,
|
|
arrowIndicatorWidth: Pixels
|
|
): Pixels {
|
|
return when (properties.popupAlignment) {
|
|
BODY_TO_ANCHOR_START,
|
|
BODY_TO_ANCHOR_CENTER -> Pixels(properties.indicatorArrowStartOffset.toPx())
|
|
INDICATOR_CENTERED_IN_ANCHOR -> {
|
|
val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2
|
|
if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) {
|
|
val visiblePopupStartCoord = popupStartCoord.value + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx()
|
|
|
|
Pixels(anchorMiddleXCoord.value - arrowIndicatorHalfWidth - visiblePopupStartCoord)
|
|
} else {
|
|
val visiblePopupEndCoord = popupStartCoord.value - CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx()
|
|
|
|
Pixels(visiblePopupEndCoord - anchorMiddleXCoord.value - arrowIndicatorHalfWidth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup and remove the current popup from the screen.
|
|
* Clients are not automatically informed about this. Use a separate call to [onDismiss] if needed.
|
|
*/
|
|
internal fun dismiss() {
|
|
anchor.removeOnAttachStateChangeListener(anchorDetachedListener)
|
|
GeckoScreenOrientation.getInstance().removeListener(orientationChangeListener)
|
|
disposeComposition()
|
|
ViewTreeLifecycleOwner.set(this, null)
|
|
this.setViewTreeSavedStateRegistryOwner(null)
|
|
windowManager.removeViewImmediate(this)
|
|
}
|
|
|
|
/**
|
|
* Create fullscreen translucent layout params.
|
|
* This will allow placing the visible popup anywhere on the screen.
|
|
*/
|
|
@VisibleForTesting
|
|
internal fun createLayoutParams(): WindowManager.LayoutParams =
|
|
WindowManager.LayoutParams().apply {
|
|
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
|
|
token = anchor.applicationWindowToken
|
|
width = WindowManager.LayoutParams.MATCH_PARENT
|
|
height = WindowManager.LayoutParams.MATCH_PARENT
|
|
format = PixelFormat.TRANSLUCENT
|
|
flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
|
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
|
}
|
|
|
|
/**
|
|
* Intended to allow querying the insets of the navigation bar.
|
|
* Value will be `0` except for when the screen is rotated by 90 degrees.
|
|
*/
|
|
private fun getLeftInsets() = ViewCompat.getRootWindowInsets(anchor)
|
|
?.getInsets(WindowInsetsCompat.Type.systemBars())?.left
|
|
?: 0.coerceAtLeast(0)
|
|
|
|
@Px
|
|
internal fun Dp.toPx(): Int {
|
|
return this.value
|
|
.dpToPx(anchor.resources.displayMetrics)
|
|
.roundToInt()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simpler [View.OnAttachStateChangeListener] only informing about
|
|
* [View.OnAttachStateChangeListener.onViewDetachedFromWindow].
|
|
*/
|
|
private class OnViewDetachedListener(val onDismiss: () -> Unit) : View.OnAttachStateChangeListener {
|
|
override fun onViewAttachedToWindow(v: View?) {
|
|
// no-op
|
|
}
|
|
|
|
override fun onViewDetachedFromWindow(v: View?) {
|
|
onDismiss()
|
|
}
|
|
}
|