/* 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() } }