From 96444ddbd36acf0987ca531574187647bc74b5f3 Mon Sep 17 00:00:00 2001 From: Mugurell Date: Thu, 22 Sep 2022 19:04:49 +0300 Subject: [PATCH] For #12855 - New CFR composable This upstreams the CFR composable already used on Fenix allowing it to be reused on other projects also. The setup process requires quite a few parameters because as it is highly customizable supporting different indicator orientations or positionings in relation to the anchor and also supporting RTL. --- .buildconfig.yml | 4 + buildSrc/src/main/java/Dependencies.kt | 2 + components/compose/cfr/README.md | 49 +++ components/compose/cfr/build.gradle | 59 +++ components/compose/cfr/proguard-rules.pro | 21 + .../compose/cfr/src/main/AndroidManifest.xml | 5 + .../components/compose/cfr/CFRPopup.kt | 166 ++++++++ .../components/compose/cfr/CFRPopupContent.kt | 171 ++++++++ .../compose/cfr/CFRPopupFullscreenLayout.kt | 375 ++++++++++++++++++ .../components/compose/cfr/CFRPopupShape.kt | 272 +++++++++++++ .../cfr/helper/DisplayOrientationListener.kt | 60 +++ .../cfr/helper/ViewDetachedListener.kt | 19 + .../cfr/src/main/res/values/strings.xml | 8 + .../cfr/CFRPopupFullscreenLayoutTest.kt | 123 ++++++ .../helper/DisplayOrientationListenerTest.kt | 95 +++++ .../cfr/helper/ViewDetachedListenerTest.kt | 32 ++ .../org.mockito.plugins.MockMaker | 2 + docs/changelog.md | 3 + taskcluster/ci/config.yml | 1 + 19 files changed, 1467 insertions(+) create mode 100644 components/compose/cfr/README.md create mode 100644 components/compose/cfr/build.gradle create mode 100644 components/compose/cfr/proguard-rules.pro create mode 100644 components/compose/cfr/src/main/AndroidManifest.xml create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopup.kt create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupContent.kt create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayout.kt create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupShape.kt create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/DisplayOrientationListener.kt create mode 100644 components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/ViewDetachedListener.kt create mode 100644 components/compose/cfr/src/main/res/values/strings.xml create mode 100644 components/compose/cfr/src/test/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayoutTest.kt create mode 100644 components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/DisplayOrientationListenerTest.kt create mode 100644 components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/ViewDetachedListenerTest.kt create mode 100644 components/compose/cfr/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/.buildconfig.yml b/.buildconfig.yml index 90a4adc283e..72597f8046c 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -8,6 +8,10 @@ projects: path: components/compose/browser-toolbar description: 'A customizable toolbar for browsers using Jetpack Compose.' publish: true + compose-cfr: + path: components/compose/cfr + description: 'A standard Contextual Feature Recommendation popup using Jetpack Compose.' + publish: true compose-engine: path: components/compose/engine description: 'A component for integrating a concept-engine implementation into Jetpack Compose UI.' diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index ae74c51d0cb..e6c16db5046 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -63,6 +63,7 @@ object Versions { const val test_ext = "1.1.3" const val espresso = "3.3.0" const val room = "2.4.3" + const val savedstate = "1.2.0" const val paging = "2.1.2" const val palette = "1.0.0" const val preferences = "1.1.1" @@ -133,6 +134,7 @@ object Dependencies { const val androidx_room_runtime = "androidx.room:room-ktx:${Versions.AndroidX.room}" const val androidx_room_compiler = "androidx.room:room-compiler:${Versions.AndroidX.room}" const val androidx_room_testing = "androidx.room:room-testing:${Versions.AndroidX.room}" + const val androidx_savedstate = "androidx.savedstate:savedstate:${Versions.AndroidX.savedstate}" const val androidx_test_core = "androidx.test:core-ktx:${Versions.AndroidX.test}" const val androidx_test_junit = "androidx.test.ext:junit-ktx:${Versions.AndroidX.test_ext}" const val androidx_test_runner = "androidx.test:runner:${Versions.AndroidX.test}" diff --git a/components/compose/cfr/README.md b/components/compose/cfr/README.md new file mode 100644 index 00000000000..843ac13f7fd --- /dev/null +++ b/components/compose/cfr/README.md @@ -0,0 +1,49 @@ +# [Android Components](../../../README.md) > Compose > Tabs tray + +A standard Contextual Feature Recommendation popup using Jetpack Compose. + +## Usage + +```kotlin +CFRPopup( + anchor = , + properties = CFRPopupProperties( + popupWidth = 256.dp, + popupAlignment = INDICATOR_CENTERED_IN_ANCHOR, + popupBodyColors = listOf( + ContextCompat.getColor(context, R.color.color1), + ContextCompat.getColor(context, R.color.color2) + ), + dismissButtonColor = ContextCompat.getColor(context, R.color.color3), + ), + onDismiss = { }, + text = { + Text( + text = stringResource(R.string.string1), + style = MaterialTheme.typography.body2, + ) + }, + action = { + Button(onClick = { }) { + Text(text = stringResource(R.string.string2)) + } + }, +).apply { + show() +} +``` + + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:compose-cfr:{latest-version}" +``` + +## License + + 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/ diff --git a/components/compose/cfr/build.gradle b/components/compose/cfr/build.gradle new file mode 100644 index 00000000000..87859ef72c9 --- /dev/null +++ b/components/compose/cfr/build.gradle @@ -0,0 +1,59 @@ +/* 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/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.compose_compiler + } + + kotlinOptions { + freeCompilerArgs += "-Xjvm-default=all" + } +} + +dependencies { + implementation project(':support-ktx') + implementation project(':ui-icons') + + implementation Dependencies.androidx_compose_ui + implementation Dependencies.androidx_compose_ui_tooling + implementation Dependencies.androidx_compose_foundation + implementation Dependencies.androidx_compose_material + implementation Dependencies.androidx_core + implementation Dependencies.androidx_core_ktx + implementation Dependencies.androidx_lifecycle_runtime + implementation Dependencies.androidx_savedstate + + testImplementation project(':support-test') + testImplementation Dependencies.androidx_test_core + testImplementation Dependencies.androidx_test_junit + testImplementation Dependencies.testing_junit + testImplementation Dependencies.testing_mockito + testImplementation Dependencies.testing_robolectric +} + +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/compose/cfr/proguard-rules.pro b/components/compose/cfr/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/components/compose/cfr/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/components/compose/cfr/src/main/AndroidManifest.xml b/components/compose/cfr/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..0d9cd04754f --- /dev/null +++ b/components/compose/cfr/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopup.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopup.kt new file mode 100644 index 00000000000..82c8b2b0a37 --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopup.kt @@ -0,0 +1,166 @@ +/* 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 mozilla.components.compose.cfr + +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection +import mozilla.components.compose.cfr.CFRPopup.PopupAlignment +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 popupAlignment Where in relation to it's anchor should the popup be placed. + * @property popupBodyColors One or more colors serving as the popup background. + * If more colors are provided they will be used in a gradient. + * @property popupVerticalOffset Vertical distance between the indicator arrow and the anchor. + * This only applies if [overlapAnchor] is `false`. + * @property dismissButtonColor The tint color that should be applied to the dismiss button. + * @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 indicatorDirection The direction the indicator arrow is pointing. + * @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 popupAlignment: PopupAlignment = PopupAlignment.BODY_TO_ANCHOR_CENTER, + val popupBodyColors: List = listOf(Color.Blue.toArgb()), + val popupVerticalOffset: Dp = CFRPopup.DEFAULT_VERTICAL_OFFSET.dp, + val dismissButtonColor: Int = Color.Black.toArgb(), + val dismissOnBackPress: Boolean = true, + val dismissOnClickOutside: Boolean = true, + val overlapAnchor: Boolean = false, + val indicatorDirection: IndicatorDirection = IndicatorDirection.UP, + val indicatorArrowStartOffset: Dp = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, +) + +/** + * CFR - Contextual Feature Recommendation popup. + * + * @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 appearance and 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 text [Text] already styled and ready to be shown in the popup. + * @param action Optional other composable to show just below the popup text. + */ +class CFRPopup( + @get:VisibleForTesting internal val anchor: View, + @get:VisibleForTesting internal val properties: CFRPopupProperties, + @get:VisibleForTesting internal val onDismiss: (Boolean) -> Unit = {}, + @get:VisibleForTesting internal val text: @Composable (() -> Unit), + @get:VisibleForTesting internal 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(anchor, properties, onDismiss, text, 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, + } + + /** + * Possible alignments of the popup in relation to it's anchor. + */ + enum class PopupAlignment { + /** + * The popup body will be centered in the space occupied by the anchor. + * Recommended to be used when the anchor is wider than the popup. + */ + BODY_TO_ANCHOR_CENTER, + + /** + * The popup body will be shown aligned to exactly the anchor start. + */ + BODY_TO_ANCHOR_START, + + /** + * The popup will be aligned such that the indicator arrow will point to exactly the middle of the anchor. + * Recommended to be used when there are multiple widgets displayed horizontally so that this will allow + * to indicate exactly which widget the popup refers to. + */ + INDICATOR_CENTERED_IN_ANCHOR, + } + + 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_EXTRA_HORIZONTAL_PADDING = 10 + + /** + * How tall the indicator arrow should be. + * This will also affect the width of the indicator's base which is double the height value. + */ + internal const val DEFAULT_INDICATOR_HEIGHT = 7 + + /** + * 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 + + /** + * Vertical distance between the indicator arrow and the anchor. + */ + internal const val DEFAULT_VERTICAL_OFFSET = 9 + } +} diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupContent.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupContent.kt new file mode 100644 index 00000000000..9b01b2f40de --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/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 mozilla.components.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.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.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.DOWN +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.UP +import mozilla.components.compose.cfr.R.drawable + +/** + * Complete content of the popup. + * [CFRPopupShape] with a gradient background containing [text] and a dismiss ("X") button. + * + * @param popupBodyColors One or more colors serving as the popup background. + * @param dismissButtonColor The tint color that should be applied to the dismiss button. + * @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 text [Text] already styled and ready to be shown in the popup. + * @param action Optional other composable to show just below the popup text. + */ +@Composable +@Suppress("LongParameterList", "LongMethod") +fun CFRPopupContent( + popupBodyColors: List, + dismissButtonColor: Int, + indicatorDirection: CFRPopup.IndicatorDirection, + indicatorArrowStartOffset: Dp, + onDismiss: (Boolean) -> Unit, + popupWidth: Dp = CFRPopup.DEFAULT_WIDTH.dp, + text: @Composable (() -> Unit), + 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_EXTRA_HORIZONTAL_PADDING.dp)) { + Surface( + color = Color.Transparent, + // Need to override the default RectangleShape to avoid casting shadows for that shape. + shape = popupShape, + modifier = Modifier + .align(Alignment.CenterStart) + .background( + shape = popupShape, + brush = Brush.linearGradient( + colors = popupBodyColors.map { Color(it) }, + 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 + }, + ), + ) { + Box( + modifier = Modifier.padding( + end = 24.dp, // 8.dp extra padding to the "X" icon + ), + ) { + text() + } + + action() + } + } + + IconButton( + onClick = { onDismiss(true) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + end = 6.dp, + ) + .size(48.dp), + ) { + Icon( + painter = painterResource(drawable.mozac_ic_close_20), + contentDescription = "Test", + modifier = Modifier + // Following alignment and padding are intended to visually align the middle + // of the "X" button with the top of the text. + .align(Alignment.Center) + .padding( + top = if (indicatorDirection == CFRPopup.IndicatorDirection.UP) 9.dp else 0.dp, + ) + .size(24.dp), + tint = Color(dismissButtonColor), + ) + } + } +} + +@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() { + CFRPopupContent( + popupBodyColors = listOf(Color.Cyan.toArgb(), Color.Blue.toArgb()), + dismissButtonColor = Color.Black.toArgb(), + indicatorDirection = DOWN, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { }, + text = { Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod") }, + ) +} + +@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() { + CFRPopupContent( + popupBodyColors = listOf(Color.Cyan.toArgb(), Color.Blue.toArgb()), + dismissButtonColor = Color.Black.toArgb(), + indicatorDirection = UP, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { }, + text = { Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod") }, + ) +} diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayout.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayout.kt new file mode 100644 index 00000000000..527442d4a4e --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayout.kt @@ -0,0 +1,375 @@ +/* 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 mozilla.components.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.material.Text +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.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.DOWN +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.UP +import mozilla.components.compose.cfr.CFRPopup.PopupAlignment.BODY_TO_ANCHOR_CENTER +import mozilla.components.compose.cfr.CFRPopup.PopupAlignment.BODY_TO_ANCHOR_START +import mozilla.components.compose.cfr.CFRPopup.PopupAlignment.INDICATOR_CENTERED_IN_ANCHOR +import mozilla.components.compose.cfr.CFRPopupShape.Companion +import mozilla.components.compose.cfr.helper.DisplayOrientationListener +import mozilla.components.compose.cfr.helper.ViewDetachedListener +import mozilla.components.support.ktx.android.util.dpToPx +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 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 text [Text] already styled and ready to be shown in the popup. + * @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 anchor: View, + private val properties: CFRPopupProperties, + private val onDismiss: (Boolean) -> Unit, + private val text: @Composable (() -> 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 = ViewDetachedListener { + 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 = DisplayOrientationListener(anchor.context) { + dismiss() + } + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + init { + ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(anchor)) + this.setViewTreeSavedStateRegistryOwner(anchor.findViewTreeSavedStateRegistryOwner()) + anchor.addOnAttachStateChangeListener(anchorDetachedListener) + orientationChangeListener.start() + } + + /** + * 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(Companion.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)), + ) + + val indicatorOffset = computeIndicatorArrowStartCoord( + anchorMiddleXCoord = anchorXCoordMiddle, + popupStartCoord = popupBounds.startCoord, + arrowIndicatorWidth = Pixels( + Companion.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value), + ), + ) + + 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( + popupBodyColors = properties.popupBodyColors, + dismissButtonColor = properties.dismissButtonColor, + indicatorDirection = properties.indicatorDirection, + indicatorArrowStartOffset = with(LocalDensity.current) { + indicatorOffset.value.toDp() + }, + onDismiss = { + // For when tapping the "X" button. + dismiss() + onDismiss(true) + }, + popupWidth = properties.popupWidth, + text = text, + 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 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) { + val startCoord = when (properties.popupAlignment) { + BODY_TO_ANCHOR_START -> { + Pixels(anchor.x.roundToInt()) + } + BODY_TO_ANCHOR_CENTER -> { + val popupWidth = (properties.popupWidth + CFRPopup.DEFAULT_EXTRA_HORIZONTAL_PADDING.dp).toPx() + Pixels(((anchor.x.roundToInt() + anchor.width) - popupWidth) / 2) + } + INDICATOR_CENTERED_IN_ANCHOR -> { + // Push the popup as far to the start as needed including any needed paddings. + Pixels( + (anchorMiddleXCoord.value - arrowIndicatorHalfWidth) + .minus(properties.indicatorArrowStartOffset.toPx()) + .coerceAtLeast(getLeftInsets()), + ) + } + } + + PopupHorizontalBounds( + startCoord = startCoord, + endCoord = Pixels( + startCoord.value + .plus(properties.popupWidth.toPx()) + .plus(CFRPopup.DEFAULT_EXTRA_HORIZONTAL_PADDING.dp.toPx()), + ), + ) + } else { + val startCoord = when (properties.popupAlignment) { + BODY_TO_ANCHOR_START -> { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp.toPx() + val visibleAnchorEnd = screenWidth - anchor.x.roundToInt() + getLeftInsets() + Pixels(visibleAnchorEnd) + } + BODY_TO_ANCHOR_CENTER -> { + val popupWidth = (properties.popupWidth + CFRPopup.DEFAULT_EXTRA_HORIZONTAL_PADDING.dp).toPx() + val screenWidth = LocalConfiguration.current.screenWidthDp.dp.toPx() + Pixels(screenWidth - ((anchor.x.roundToInt() + anchor.width) - popupWidth) / 2) + } + INDICATOR_CENTERED_IN_ANCHOR -> { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp.toPx() + Pixels( + // Push the popup as far to the start (in RTL) as possible. + anchorMiddleXCoord.value + .plus(arrowIndicatorHalfWidth) + .plus(properties.indicatorArrowStartOffset.toPx()) + .coerceAtMost(screenWidth + getLeftInsets()), + ) + } + } + + PopupHorizontalBounds( + startCoord = startCoord, + endCoord = Pixels( + startCoord.value + .minus(properties.popupWidth.toPx()) + .minus(CFRPopup.DEFAULT_EXTRA_HORIZONTAL_PADDING.dp.toPx()), + ), + ) + } + } + + /** + * 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) { + Pixels(anchorMiddleXCoord.value - arrowIndicatorHalfWidth - popupStartCoord.value) + } else { + val visiblePopupEndCoord = popupStartCoord.value + 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) + orientationChangeListener.stop() + 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() + } +} diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupShape.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupShape.kt new file mode 100644 index 00000000000..8606bdcebc0 --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupShape.kt @@ -0,0 +1,272 @@ +/* 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 mozilla.components.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.MaterialTheme +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.Color +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 mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.DOWN +import mozilla.components.compose.cfr.CFRPopup.IndicatorDirection.UP +import kotlin.math.roundToInt + +/** + * How wide the base of the indicator should be in relation with the indicator's height. + */ +private const val INDICATOR_BASE_TO_HEIGHT_RATIO = 2f + +/** + * 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() { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(UP, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf(Color.Cyan, Color.Blue), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f), + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "This is just a test", + color = MaterialTheme.colors.onPrimary, + ) + } +} + +@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() { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(DOWN, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf(Color.Cyan, Color.Blue), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f), + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "This is just a test", + color = MaterialTheme.colors.onPrimary, + ) + } +} diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/DisplayOrientationListener.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/DisplayOrientationListener.kt new file mode 100644 index 00000000000..0c781fa6195 --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/DisplayOrientationListener.kt @@ -0,0 +1,60 @@ +/* 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 mozilla.components.compose.cfr.helper + +import android.content.Context +import android.content.pm.ActivityInfo +import android.hardware.display.DisplayManager +import android.hardware.display.DisplayManager.DisplayListener +import androidx.annotation.VisibleForTesting + +/** + * Inform when the rotation of the screen changes. + * Since this is using a [DisplayManager] listener it's important to call [start] and [stop] + * at the appropriate moments to register and unregister said listener. + * + * @param context Android context needed to interact with the [DisplayManager] + * @param onDisplayRotationChanged Listener for when the display rotation changes. + * This will be called when the display changes to any of the four main orientations: + * [PORTRAIT, LANDSCAPE, REVERSE_PORTRAIT, REVERSE_LANDSCAPE]. + * No updates will be triggered if the "Auto-rotate" functionality is disabled for the device. + */ +internal class DisplayOrientationListener( + context: Context, + val onDisplayRotationChanged: () -> Unit, +) : DisplayListener { + private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + + @VisibleForTesting + internal var currentOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + + /** + * Start listening for display orientation changes. + * It's important to also call [stop] when done listening to prevent leaking the listener. + */ + fun start() { + displayManager.registerDisplayListener(this, null) + } + + /** + * Stop listening for display orientation changes and cleanup the current [DisplayManager] listener. + */ + fun stop() { + displayManager.unregisterDisplayListener(this) + } + + override fun onDisplayAdded(displayId: Int) = Unit + + override fun onDisplayRemoved(displayId: Int) = Unit + + override fun onDisplayChanged(displayId: Int) { + val display = displayManager.getDisplay(displayId) + + if (display.rotation != currentOrientation) { + currentOrientation = display.rotation + onDisplayRotationChanged() + } + } +} diff --git a/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/ViewDetachedListener.kt b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/ViewDetachedListener.kt new file mode 100644 index 00000000000..aa0ef56113f --- /dev/null +++ b/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/helper/ViewDetachedListener.kt @@ -0,0 +1,19 @@ +/* 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 mozilla.components.compose.cfr.helper + +import android.view.View + +/** + * Simpler [View.OnAttachStateChangeListener] only informing about + * [View.OnAttachStateChangeListener.onViewDetachedFromWindow]. + */ +internal class ViewDetachedListener(val onDismiss: () -> Unit) : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View?) = Unit + + override fun onViewDetachedFromWindow(v: View?) { + onDismiss() + } +} diff --git a/components/compose/cfr/src/main/res/values/strings.xml b/components/compose/cfr/src/main/res/values/strings.xml new file mode 100644 index 00000000000..86c00ee3c08 --- /dev/null +++ b/components/compose/cfr/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + + Dismiss + diff --git a/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayoutTest.kt b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayoutTest.kt new file mode 100644 index 00000000000..c171f839a37 --- /dev/null +++ b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/CFRPopupFullscreenLayoutTest.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 mozilla.components.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.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +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.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class CFRPopupFullscreenLayoutTest { + @Test + fun `WHEN the popup is constructed THEN setup lifecycle owners`() { + val anchor = View(testContext).apply { + ViewTreeLifecycleOwner.set(this, mock()) + this.setViewTreeSavedStateRegistryOwner(mock()) + } + + val popupView = spy( + CFRPopupFullscreenLayout( + anchor = anchor, + properties = mock(), + onDismiss = mock(), + text = { }, + action = { }, + ), + ) + + 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 = spy(testContext) + val anchor = View(context).apply { + ViewTreeLifecycleOwner.set(this, mock()) + this.setViewTreeSavedStateRegistryOwner(mock()) + } + val windowManager = spy(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager) + doReturn(windowManager).`when`(context).getSystemService(Context.WINDOW_SERVICE) + val popupView = CFRPopupFullscreenLayout(anchor, mock(), mock(), { }, { }) + 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 = spy(testContext) + val anchor = View(context) + val windowManager = spy(context.getSystemService(Context.WINDOW_SERVICE)) + doReturn(windowManager).`when`(context).getSystemService(Context.WINDOW_SERVICE) + val popupView = CFRPopupFullscreenLayout(anchor, mock(), mock(), { }, { }) + val layoutParamsCaptor = argumentCaptor() + + popupView.show() + + verify(windowManager as ViewManager).addView(eq(popupView), layoutParamsCaptor.capture()) + assertEquals(LayoutParams.TYPE_APPLICATION_PANEL, layoutParamsCaptor.value.type) + assertEquals(anchor.applicationWindowToken, layoutParamsCaptor.value.token) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.value.width) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.value.height) + assertEquals(PixelFormat.TRANSLUCENT, layoutParamsCaptor.value.format) + assertEquals( + LayoutParams.FLAG_LAYOUT_IN_SCREEN or LayoutParams.FLAG_HARDWARE_ACCELERATED, + layoutParamsCaptor.value.flags, + ) + } + + @Test + fun `WHEN creating layout params THEN get fullscreen translucent layout params`() { + val anchor = View(testContext) + val popupView = CFRPopupFullscreenLayout(anchor, mock(), mock(), { }, { }) + + 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, + ) + } +} diff --git a/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/DisplayOrientationListenerTest.kt b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/DisplayOrientationListenerTest.kt new file mode 100644 index 00000000000..47da45ea416 --- /dev/null +++ b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/DisplayOrientationListenerTest.kt @@ -0,0 +1,95 @@ +/* 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 mozilla.components.compose.cfr.helper + +import android.content.Context +import android.content.pm.ActivityInfo +import android.hardware.display.DisplayManager +import android.view.Display +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify + +class DisplayOrientationListenerTest { + private val context: Context = mock() + private val displayManager: DisplayManager = mock() + + @Before + fun setup() { + doReturn(displayManager).`when`(context).getSystemService(Context.DISPLAY_SERVICE) + } + + @Test + fun `WHEN started THEN register it as a display listener`() { + val listener = DisplayOrientationListener(context) { } + + listener.start() + + verify(displayManager).registerDisplayListener(listener, null) + } + + @Test + fun `WHEN stopped THEN unregister from being a display listener`() { + val listener = DisplayOrientationListener(context) { } + + listener.stop() + + verify(displayManager).unregisterDisplayListener(listener) + } + + @Test + fun `WHEN a display is added THEN don't inform the client`() { + var hasRotationChanged = false + val listener = DisplayOrientationListener(context) { hasRotationChanged = true } + + listener.onDisplayAdded(1) + + assertFalse(hasRotationChanged) + } + + @Test + fun `WHEN a display is removed THEN don't inform the client`() { + var hasRotationChanged = false + val listener = DisplayOrientationListener(context) { hasRotationChanged = true } + + listener.onDisplayRemoved(1) + + assertFalse(hasRotationChanged) + } + + @Test + fun `WHEN a display is changed but doesn't have a new rotation THEN don't inform the client`() { + var hasRotationChanged = false + val listener = DisplayOrientationListener(context) { hasRotationChanged = true } + listener.currentOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + val display: Display = mock() + doReturn(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE).`when`(display).rotation + doReturn(display).`when`(displayManager).getDisplay(1) + + listener.onDisplayChanged(1) + + assertFalse(hasRotationChanged) + } + + @Test + fun `WHEN a display is changed and has a new rotation THEN inform the client and remember the new rotation`() { + var hasRotationChanged = false + val listener = DisplayOrientationListener(context) { hasRotationChanged = true } + listener.currentOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + val display: Display = mock() + doReturn(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE).`when`(display).rotation + doReturn(display).`when`(displayManager).getDisplay(1) + + listener.onDisplayChanged(1) + + assertTrue(hasRotationChanged) + assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, listener.currentOrientation) + } +} diff --git a/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/ViewDetachedListenerTest.kt b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/ViewDetachedListenerTest.kt new file mode 100644 index 00000000000..bb6467f44cf --- /dev/null +++ b/components/compose/cfr/src/test/java/mozilla/components/compose/cfr/helper/ViewDetachedListenerTest.kt @@ -0,0 +1,32 @@ +/* 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 mozilla.components.compose.cfr.helper + +import mozilla.components.support.test.mock +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ViewDetachedListenerTest { + @Test + fun `WHEN the View is attached THEN don't inform the client`() { + var wasCallbackCalled = false + val listener = ViewDetachedListener { wasCallbackCalled = true } + + listener.onViewAttachedToWindow(mock()) + + assertFalse(wasCallbackCalled) + } + + @Test + fun `WHEN the View is detached THEN don't inform the client`() { + var wasCallbackCalled = false + val listener = ViewDetachedListener { wasCallbackCalled = true } + + listener.onViewDetachedFromWindow(mock()) + + assertTrue(wasCallbackCalled) + } +} diff --git a/components/compose/cfr/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/components/compose/cfr/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..cf1c399ea81 --- /dev/null +++ b/components/compose/cfr/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/docs/changelog.md b/docs/changelog.md index 04dc9a4526f..46d818ca722 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,9 @@ permalink: /changelog/ * [Gecko](https://github.com/mozilla-mobile/android-components/blob/main/buildSrc/src/main/java/Gecko.kt) * [Configuration](https://github.com/mozilla-mobile/android-components/blob/main/.config.yml) +* **compose-cfr** + * 🆕 New composable popup allowing to offer more context about a specific View anchor on the screen. Supports RTL along with many other customizations and anchorings. + * **feature-qr** * QRFeature now allows querying if scanning is in progress with a new `isScanInProgress` property. This helps deciding on whether to resume scanning by calling `scan` in a new `QRFeature` instance as it can happen if the process is restarted as a followup to the user updating system permissions for the app. diff --git a/taskcluster/ci/config.yml b/taskcluster/ci/config.yml index b9835fd7d51..db18d3c24fe 100644 --- a/taskcluster/ci/config.yml +++ b/taskcluster/ci/config.yml @@ -22,6 +22,7 @@ treeherder: browser-toolbar: browser-toolbar compose-awesomebar: compose-engine compose-browser-toolbar: awesomebar-browser-toolbar + compose-cfr: compose-cfr compose-engine: compose-engine compose-tabstray: compose-tabstray concept-awesomebar: concept-awesomebar