From 848b8b583ddbe5114b5703834c36c77b76dfae1e Mon Sep 17 00:00:00 2001 From: Mugurell Date: Mon, 25 Jul 2022 18:55:08 +0300 Subject: [PATCH] [fenix] For https://github.com/mozilla-mobile/fenix/issues/26172 - New CFR popup composable This will allow for pinpoint accuracy when anchoring and resolve any color disparities between the popup body and the indicator arrow by having everything drawn programmatically as one shape. Because of the async nature of the values for insets and screen rotation immediately after an orientation change the popup will automatically get dismissed to prevent any anchoring issues. While not ideal the effect of this is better than accepting layout issues after orientation changes and is the same approach used for other of our popups. --- .../org/mozilla/fenix/compose/cfr/CFRPopup.kt | 123 ++++++ .../fenix/compose/cfr/CFRPopupContent.kt | 171 +++++++++ .../compose/cfr/CFRPopupFullscreenLayout.kt | 361 ++++++++++++++++++ .../fenix/compose/cfr/CFRPopupShape.kt | 252 ++++++++++++ app/src/main/res/values/strings.xml | 3 + .../cfr/CFRPopupFullScreenLayoutTest.kt | 114 ++++++ 6 files changed, 1024 insertions(+) create mode 100644 app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt create mode 100644 app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt new file mode 100644 index 0000000000..d2fae90525 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt @@ -0,0 +1,123 @@ +/* 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.view.View +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.lang.ref.WeakReference + +/** + * Properties used to customize the behavior of a [CFRPopup]. + * + * @property popupWidth Width of the popup. Defaults to [CFRPopup.DEFAULT_WIDTH]. + * @property indicatorDirection The direction the indicator arrow is pointing. + * @property dismissOnBackPress Whether the popup can be dismissed by pressing the back button. + * If true, pressing the back button will also call onDismiss(). + * @property dismissOnClickOutside Whether the popup can be dismissed by clicking outside the + * popup's bounds. If true, clicking outside the popup will call onDismiss(). + * @property overlapAnchor How the popup's indicator will be shown in relation to the anchor: + * - true - indicator will be shown exactly in the middle horizontally and vertically + * - false - indicator will be shown horizontally in the middle of the anchor but immediately below or above it + * @property indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. + * If there isn't enough space this could automatically be overridden up to 0 such that + * the indicator arrow will be pointing to the middle of the anchor. + */ +data class CFRPopupProperties( + val popupWidth: Dp = CFRPopup.DEFAULT_WIDTH.dp, + val indicatorDirection: CFRPopup.IndicatorDirection = CFRPopup.IndicatorDirection.UP, + val dismissOnBackPress: Boolean = true, + val dismissOnClickOutside: Boolean = true, + val overlapAnchor: Boolean = false, + val indicatorArrowStartOffset: Dp = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, +) + +/** + * CFR - Contextual Feature Recommendation popup. + * + * @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. + */ +class CFRPopup( + private val text: String, + private val anchor: View, + private val properties: CFRPopupProperties = CFRPopupProperties(), + private val onDismiss: (Boolean) -> Unit = {}, + private val action: @Composable (() -> Unit) = {} +) { + // This is just a facade for the CFRPopupFullScreenLayout composable offering a cleaner API. + + @VisibleForTesting + internal var popup: WeakReference? = null + + /** + * Construct and display a styled CFR popup shown at the coordinates of [anchor]. + * 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() { + anchor.post { + CFRPopupFullScreenLayout(text, anchor, properties, onDismiss, action).apply { + this.show() + popup = WeakReference(this) + } + } + } + + /** + * Immediately dismiss this CFR popup. + * The [onDismiss] callback won't be fired. + */ + fun dismiss() { + popup?.get()?.dismiss() + } + + /** + * Possible direction for the arrow indicator of a CFR popup. + * The direction is expressed in relation with the popup body containing the text. + */ + enum class IndicatorDirection { + UP, + DOWN + } + + companion object { + /** + * Default width for all CFRs. + */ + internal const val DEFAULT_WIDTH = 335 + + /** + * Fixed horizontal padding. + * Allows the close button to extend with 10dp more to the end and intercept touches to + * a bit outside of the popup to ensure it respects a11y recommendations of 48dp size while + * also offer a bit more space to the text. + */ + internal const val DEFAULT_HORIZONTAL_PADDING = 10 + + /** + * How tall the indicator arrow should be. + * This will also affect how wide the base of the indicator arrow will be. + */ + internal const val DEFAULT_INDICATOR_HEIGHT = 15 + + /** + * Maximum distance between the popup start and the indicator. + */ + internal const val DEFAULT_INDICATOR_START_OFFSET = 30 + + /** + * Corner radius for the popup body. + */ + internal const val DEFAULT_CORNER_RADIUS = 12 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt new file mode 100644 index 0000000000..dfd7e28899 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt @@ -0,0 +1,171 @@ +/* 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.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons.Filled +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.R +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Complete content of the popup. + * [CFRPopupShape] with a gradient background containing [text] and a dismiss ("X") button. + * + * @param text String message in the popup. + * @param indicatorDirection The direction the indicator arrow is pointing to. + * @param indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. + * If there isn't enough space this could automatically be overridden up to 0. + * @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. + */ +@Composable +@Suppress("LongParameterList", "LongMethod") +fun CFRPopupContent( + text: String, + indicatorDirection: CFRPopup.IndicatorDirection, + indicatorArrowStartOffset: Dp, + onDismiss: (Boolean) -> Unit, + popupWidth: Dp = CFRPopup.DEFAULT_WIDTH.dp, + action: @Composable (() -> Unit) = {} +) { + val popupShape = CFRPopupShape( + indicatorDirection, + indicatorArrowStartOffset, + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp, + CFRPopup.DEFAULT_CORNER_RADIUS.dp, + ) + + Box(modifier = Modifier.width(popupWidth + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp * 2)) { + Surface( + color = Color.Transparent, + // Need to override the default RectangleShape to avoid casting shadows for that shape. + shape = popupShape, + modifier = Modifier + .align(Alignment.Center) + .background( + shape = popupShape, + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientEnd, + FirefoxTheme.colors.gradientStart + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ) + .wrapContentHeight() + .width(popupWidth) + ) { + + Column( + modifier = Modifier + .padding( + start = 16.dp, + top = 16.dp + if (indicatorDirection == CFRPopup.IndicatorDirection.UP) { + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp + } else { + 0.dp + }, + end = 16.dp, + bottom = 16.dp + + if (indicatorDirection == CFRPopup.IndicatorDirection.DOWN) { + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp + } else { + 0.dp + } + ) + ) { + Text( + text = text, + modifier = Modifier.padding( + end = 24.dp, // 8.dp extra padding to the "X" icon + ), + color = FirefoxTheme.colors.textOnColorPrimary, + style = FirefoxTheme.typography.body2 + ) + + action() + } + } + + IconButton( + onClick = { onDismiss(true) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = if (indicatorDirection == CFRPopup.IndicatorDirection.UP) 14.dp else 0.dp, + end = 6.dp + ) + .size(48.dp) + ) { + Icon( + imageVector = Filled.Close, + contentDescription = stringResource(R.string.cfr_dismiss_button_default_content_description), + modifier = Modifier + // Following alignment and padding are necessary to visually align the middle + // of the "X" button with the top of the text. + .align(Alignment.TopCenter) + .padding(top = 10.dp) + .size(24.dp), + tint = FirefoxTheme.colors.iconOnColor + ) + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupAbovePreview() { + FirefoxTheme { + CFRPopupContent( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + indicatorDirection = CFRPopup.IndicatorDirection.DOWN, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { } + ) + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupBelowPreview() { + FirefoxTheme { + CFRPopupContent( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + indicatorDirection = CFRPopup.IndicatorDirection.UP, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt new file mode 100644 index 0000000000..3cfd163ceb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt @@ -0,0 +1,361 @@ +/* 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.lifecycle.ViewTreeLifecycleOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +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.theme.FirefoxTheme +import org.mozilla.gecko.GeckoScreenOrientation +import kotlin.math.absoluteValue +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)) + ViewTreeSavedStateRegistryOwner.set(this, ViewTreeSavedStateRegistryOwner.get(anchor)) + 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 anchorXCoordMiddle = Pixels(anchorLocation.first() + anchor.width / 2) + val indicatorArrowHeight = Pixels( + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp.toPx() + ) + + val popupBounds = computePopupHorizontalBounds( + anchorMiddleXCoord = anchorXCoordMiddle, + arrowIndicatorWidth = Pixels(CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)), + ) + val indicatorOffset = computeIndicatorArrowStartCoord( + anchorMiddleXCoord = anchorXCoordMiddle, + 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 + } + } + DOWN -> { + when (properties.overlapAnchor) { + true -> anchorLocation.last() - popupContentSize.height + anchor.height / 2 + else -> anchorLocation.last() - popupContentSize.height + } + } + } + ) + } + } + } + + /** + * 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 anchorMiddleXCoord x-coordinate for the middle of the anchor. + * @param arrowIndicatorWidth x-distance the arrow indicator occupies. + */ + @Composable + private fun computePopupHorizontalBounds( + anchorMiddleXCoord: Pixels, + arrowIndicatorWidth: Pixels + ): PopupHorizontalBounds { + val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 + + return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + // Push the popup as far to the start as needed including any needed paddings. + val startCoord = 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 = 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( + LocalDensity.current.run { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + .roundToInt() + .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 { + val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 + + return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + val visiblePopupStartCoord = popupStartCoord.value + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() + val arrowIndicatorStartCoord = anchorMiddleXCoord.value - arrowIndicatorHalfWidth + + Pixels((visiblePopupStartCoord - arrowIndicatorStartCoord).absoluteValue) + } else { + val indicatorStartCoord = popupStartCoord.value - CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() - + anchorMiddleXCoord.value - arrowIndicatorHalfWidth + + Pixels(indicatorStartCoord.absoluteValue) + } + } + + /** + * 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) + ViewTreeSavedStateRegistryOwner.set(this, 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() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt new file mode 100644 index 0000000000..0924f0b136 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt @@ -0,0 +1,252 @@ +/* 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.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.theme.FirefoxTheme +import kotlin.math.roundToInt + +private const val INDICATOR_BASE_TO_HEIGHT_RATIO = 1f + +/** + * A [Shape] describing a popup with an indicator triangle shown above or below the popup. + * + * @param indicatorDirection The direction the indicator arrow is pointing to. + * @param indicatorArrowStartOffset Distance between the popup start and the indicator arrow start + * @param indicatorArrowHeight Height of the indicator triangle. This influences the base length. + * @param cornerRadius The radius of the popup's corners. + * If [indicatorArrowStartOffset] is `0` then the top-start corner will not be rounded. + */ +class CFRPopupShape( + private val indicatorDirection: CFRPopup.IndicatorDirection, + private val indicatorArrowStartOffset: Dp, + private val indicatorArrowHeight: Dp, + private val cornerRadius: Dp +) : Shape { + @Suppress("LongMethod") + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val indicatorArrowStartOffsetPx = indicatorArrowStartOffset.value * density.density + val indicatorArrowHeightPx = indicatorArrowHeight.value * density.density + val indicatorArrowBasePx = + getIndicatorBaseWidthForHeight((indicatorArrowHeight.value * density.density).roundToInt()) + val cornerRadiusPx = cornerRadius.value * density.density + val indicatorCornerRadiusPx = cornerRadiusPx.coerceAtMost(indicatorArrowStartOffsetPx) + + // All outlines are drawn in a LTR space but with accounting for the LTR direction. + return when (indicatorDirection) { + CFRPopup.IndicatorDirection.UP -> { + Outline.Generic( + path = Path().apply { + reset() + + lineTo(0f, size.height - cornerRadiusPx) + quadraticBezierTo( + 0f, size.height, + cornerRadiusPx, size.height + ) + + lineTo(size.width - cornerRadiusPx, size.height) + quadraticBezierTo( + size.width, size.height, + size.width, size.height - cornerRadiusPx + ) + + if (layoutDirection == LayoutDirection.Ltr) { + lineTo(size.width, cornerRadiusPx + indicatorArrowHeightPx) + quadraticBezierTo( + size.width, indicatorArrowHeightPx, + size.width - cornerRadiusPx, indicatorArrowHeightPx + ) + + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx, indicatorArrowHeightPx) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx / 2, 0f) + lineTo(indicatorArrowStartOffsetPx, indicatorArrowHeightPx) + + lineTo(indicatorCornerRadiusPx, indicatorArrowHeightPx) + quadraticBezierTo( + 0f, indicatorArrowHeightPx, + 0f, indicatorArrowHeightPx + indicatorCornerRadiusPx + ) + } else { + lineTo(size.width, indicatorCornerRadiusPx + indicatorArrowHeightPx) + quadraticBezierTo( + size.width, indicatorArrowHeightPx, + size.width - indicatorCornerRadiusPx, indicatorArrowHeightPx + ) + + val indicatorEnd = size.width - indicatorArrowStartOffsetPx + lineTo(indicatorEnd, indicatorArrowHeightPx) + lineTo(indicatorEnd - indicatorArrowBasePx / 2, 0f) + lineTo(indicatorEnd - indicatorArrowBasePx, indicatorArrowHeightPx) + + lineTo(cornerRadiusPx, indicatorArrowHeightPx) + quadraticBezierTo( + 0f, indicatorArrowHeightPx, + 0f, indicatorArrowHeightPx + cornerRadiusPx + ) + } + + close() + } + ) + } + CFRPopup.IndicatorDirection.DOWN -> { + val messageBodyHeightPx = size.height - indicatorArrowHeightPx + + Outline.Generic( + path = Path().apply { + reset() + + if (layoutDirection == LayoutDirection.Ltr) { + lineTo(0f, messageBodyHeightPx - indicatorCornerRadiusPx) + quadraticBezierTo( + 0f, size.height - indicatorArrowHeightPx, + indicatorCornerRadiusPx, size.height - indicatorArrowHeightPx + ) + + lineTo(indicatorArrowStartOffsetPx, messageBodyHeightPx) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx / 2, size.height) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx, messageBodyHeightPx) + + lineTo(size.width - cornerRadiusPx, messageBodyHeightPx) + quadraticBezierTo( + size.width, messageBodyHeightPx, + size.width, messageBodyHeightPx - cornerRadiusPx + ) + } else { + lineTo(0f, messageBodyHeightPx - cornerRadiusPx) + quadraticBezierTo( + 0f, messageBodyHeightPx, + cornerRadiusPx, messageBodyHeightPx + ) + + val indicatorStartPx = size.width - indicatorArrowStartOffsetPx - indicatorArrowBasePx + lineTo(indicatorStartPx, messageBodyHeightPx) + lineTo(indicatorStartPx + indicatorArrowBasePx / 2, size.height) + lineTo(indicatorStartPx + indicatorArrowBasePx, messageBodyHeightPx) + + lineTo(size.width - indicatorCornerRadiusPx, messageBodyHeightPx) + quadraticBezierTo( + size.width, messageBodyHeightPx, + size.width, messageBodyHeightPx - indicatorCornerRadiusPx + ) + } + + lineTo(size.width, cornerRadiusPx) + quadraticBezierTo( + size.width, 0f, + size.width - cornerRadiusPx, 0f + ) + + lineTo(cornerRadiusPx, 0f) + quadraticBezierTo( + 0f, 0f, + 0f, cornerRadiusPx + ) + + close() + } + ) + } + } + } + + companion object { + /** + * This [Shape]'s arrow indicator will have an automatic width depending on the set height. + * This method allows knowing what the base width will be before instantiating the class. + */ + fun getIndicatorBaseWidthForHeight(height: Int): Int { + return (height * INDICATOR_BASE_TO_HEIGHT_RATIO).roundToInt() + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupBelowShapePreview() { + FirefoxTheme { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(CFRPopup.IndicatorDirection.UP, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientStart, + FirefoxTheme.colors.gradientEnd + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "This is just a test", + color = FirefoxTheme.colors.textOnColorPrimary + ) + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupAboveShapePreview() { + FirefoxTheme { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(CFRPopup.IndicatorDirection.DOWN, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientStart, + FirefoxTheme.colors.gradientEnd + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "This is just a test", + color = FirefoxTheme.colors.textOnColorPrimary + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cc85f4fb0..327e0cc6f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,9 @@ Dismiss + + Dismiss + Camera access needed. Go to Android settings, tap permissions, and tap allow. diff --git a/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt b/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt new file mode 100644 index 0000000000..4c3cec58be --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt @@ -0,0 +1,114 @@ +/* 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.content.Context +import android.graphics.PixelFormat +import android.view.View +import android.view.ViewManager +import android.view.WindowManager +import android.view.WindowManager.LayoutParams +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +@RunWith(FenixRobolectricTestRunner::class) +class CFRPopupFullScreenLayoutTest { + @Test + fun `WHEN the popup is constructed THEN setup lifecycle owners`() { + val anchor = View(testContext).apply { + ViewTreeLifecycleOwner.set(this, mockk()) + ViewTreeSavedStateRegistryOwner.set(this, mockk()) + } + + val popupView = spyk(CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {}) + + assertNotNull(popupView.findViewTreeLifecycleOwner()) + assertEquals( + anchor.findViewTreeLifecycleOwner(), + popupView.findViewTreeLifecycleOwner() + ) + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + assertEquals( + assertNotNull(anchor.findViewTreeSavedStateRegistryOwner()), + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + ) + } + + @Test + fun `WHEN the popup is dismissed THEN cleanup lifecycle owners and detach from window`() { + val context = spyk(testContext) + val anchor = View(context).apply { + ViewTreeLifecycleOwner.set(this, mockk()) + ViewTreeSavedStateRegistryOwner.set(this, mockk()) + } + val windowManager = spyk(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager) + every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + popupView.show() + assertNotNull(popupView.findViewTreeLifecycleOwner()) + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + + popupView.dismiss() + + assertNull(popupView.findViewTreeLifecycleOwner()) + assertNull(popupView.findViewTreeSavedStateRegistryOwner()) + verify { windowManager.removeViewImmediate(popupView) } + } + + @Test + fun `GIVEN a popup WHEN adding it to window THEN use translucent layout params`() { + val context = spyk(testContext) + val anchor = View(context) + val windowManager = spyk(context.getSystemService(Context.WINDOW_SERVICE)) + every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + val layoutParamsCaptor = slot() + + popupView.show() + + verify { (windowManager as ViewManager).addView(eq(popupView), capture(layoutParamsCaptor)) } + assertEquals(LayoutParams.TYPE_APPLICATION_PANEL, layoutParamsCaptor.captured.type) + assertEquals(anchor.applicationWindowToken, layoutParamsCaptor.captured.token) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.captured.width) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.captured.height) + assertEquals(PixelFormat.TRANSLUCENT, layoutParamsCaptor.captured.format) + assertEquals( + LayoutParams.FLAG_LAYOUT_IN_SCREEN or LayoutParams.FLAG_HARDWARE_ACCELERATED, + layoutParamsCaptor.captured.flags + ) + } + + @Test + fun `WHEN creating layout params THEN get fullscreen translucent layout params`() { + val anchor = View(testContext) + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + + val result = popupView.createLayoutParams() + + assertEquals(LayoutParams.TYPE_APPLICATION_PANEL, result.type) + assertEquals(anchor.applicationWindowToken, result.token) + assertEquals(LayoutParams.MATCH_PARENT, result.width) + assertEquals(LayoutParams.MATCH_PARENT, result.height) + assertEquals(PixelFormat.TRANSLUCENT, result.format) + assertEquals( + LayoutParams.FLAG_LAYOUT_IN_SCREEN or LayoutParams.FLAG_HARDWARE_ACCELERATED, + result.flags + ) + } +}