For #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.
This commit is contained in:
Mugurell 2022-07-25 18:55:08 +03:00 committed by mergify[bot]
parent 577ec0b659
commit aa992263d6
6 changed files with 1024 additions and 0 deletions

View File

@ -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<CFRPopupFullScreenLayout>? = 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
}
}

View File

@ -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 = { }
)
}
}

View File

@ -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()
}
}

View File

@ -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
)
}
}
}

View File

@ -73,6 +73,9 @@
<!-- Text for the negative action button -->
<string name="open_in_app_cfr_negative_button_text">Dismiss</string>
<!-- Content description for close button used in "contextual feature recommendation" (CFR) popups -->
<string name="cfr_dismiss_button_default_content_description">Dismiss</string>
<!-- Text for the info dialog when camera permissions have been denied but user tries to access a camera feature. -->
<string name="camera_permissions_needed_message">Camera access needed. Go to Android settings, tap permissions, and tap allow.</string>
<!-- Text for the positive action button to go to Android Settings to grant permissions. -->

View File

@ -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<LayoutParams>()
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
)
}
}