From 59ff9007a790a2e5070d0f68033bf5449dfaed91 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 15:14:24 +0100 Subject: [PATCH] Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere). Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works. --- amethyst/build.gradle | 1 + .../compose/material3/adaptive/Posture.kt | 182 ++++++++++++++++++ .../material3/adaptive/WindowAdaptiveInfo.kt | 122 ++++++++++++ .../ui/components/ZoomableContentView.kt | 49 +++-- .../ui/components/util}/DeviceUtils.kt | 43 ++++- gradle/libs.versions.toml | 2 + 6 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt rename {quartz/src/main/java/com/vitorpamplona/quartz/utils => amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util}/DeviceUtils.kt (53%) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 524d38620..7fd13c397 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -252,6 +252,7 @@ dependencies { // Language picker and Theme chooser implementation libs.androidx.appcompat + implementation libs.androidx.window.core.android // Local model for language identification playImplementation libs.google.mlkit.language.id diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt new file mode 100644 index 000000000..8331338f9 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.window.layout.FoldingFeature + +/** + * Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework + * folding info into the Material-opinionated posture info. + */ +fun calculatePosture(foldingFeatures: List): Posture { + var isTableTop = false + val hingeList = mutableListOf() + @Suppress("ListIterator") + foldingFeatures.forEach { + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL && + it.state == FoldingFeature.State.HALF_OPENED + ) { + isTableTop = true + } + hingeList.add( + HingeInfo( + bounds = it.bounds.toComposeRect(), + isFlat = it.state == FoldingFeature.State.FLAT, + isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL, + isSeparating = it.isSeparating, + isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL, + ), + ) + } + return Posture(isTableTop, hingeList) +} + +/** + * Posture info that can help make layout adaptation decisions. For example when + * [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any + * content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this + * class in applications, unless you have a strong need of customization that cannot be fulfilled by + * the default implementation. + * + * Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout + * coordinate. + * + * @constructor create an instance of [Posture] + * @property isTabletop `true` if the current window is considered as in the table top mode, i.e. + * there is one half-opened horizontal hinge in the middle of the current window. When + * this is `true` it usually means it's hard for users to interact with the window area + * around the hinge and developers may consider separating the layout along the hinge and + * show software keyboard or other controls in the bottom half of the window. + * @property hingeList a list of all hinges that are relevant to the posture. + */ +@Immutable +class Posture( + val isTabletop: Boolean = false, + val hingeList: List = emptyList(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Posture) return false + if (isTabletop != other.isTabletop) return false + if (hingeList != other.hingeList) return false + return true + } + + override fun hashCode(): Int { + var result = isTabletop.hashCode() + result = 31 * result + hingeList.hashCode() + return result + } + + override fun toString(): String { + @Suppress("ListIterator") + return "Posture(isTabletop=$isTabletop, " + + "hinges=[${hingeList.joinToString(", ")}])" + } +} + +/** + * Returns the list of vertical hinge bounds that are separating. + */ +val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating } + +/** + * Returns the list of vertical hinge bounds that are occluding. + */ +val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding } + +/** + * Returns the list of all vertical hinge bounds. + */ +val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical } + +/** + * Returns the list of horizontal hinge bounds that are separating. + */ +val Posture.separatingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isSeparating } + +/** + * Returns the list of horizontal hinge bounds that are occluding. + */ +val Posture.occludingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isOccluding } + +/** + * Returns the list of all horizontal hinge bounds. + */ +val Posture.allHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical } + +/** + * A class that contains the info of a hinge relevant to a [Posture]. + * + * @param bounds the bounds of the hinge in the relevant viewport. + * @param isFlat `true` if the hinge is fully open and the relevant window space presented to the + * user is flat. + * @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into + * left and right; `false` if the hinge is horizontal, i.e., it separates the viewport + * into top and bottom. + * @param isSeparating `true` if the hinge creates two logical display areas. + * @param isOccluding `true` if the hinge conceals part of the display. + */ +@Immutable +class HingeInfo( + val bounds: Rect, + val isFlat: Boolean, + val isVertical: Boolean, + val isSeparating: Boolean, + val isOccluding: Boolean, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HingeInfo) return false + if (bounds != other.bounds) return false + if (isFlat != other.isFlat) return false + if (isVertical != other.isVertical) return false + if (isSeparating != other.isSeparating) return false + if (isOccluding != other.isOccluding) return false + return true + } + + override fun hashCode(): Int { + var result = bounds.hashCode() + result = 31 * result + isFlat.hashCode() + result = 31 * result + isVertical.hashCode() + result = 31 * result + isSeparating.hashCode() + result = 31 * result + isOccluding.hashCode() + return result + } + + override fun toString(): String = + "HingeInfo(bounds=$bounds, " + + "isFlat=$isFlat, " + + "isVertical=$isVertical, " + + "isSeparating=$isSeparating, " + + "isOccluding=$isOccluding)" +} + +private inline fun List.getBounds(predicate: HingeInfo.() -> Boolean): List = + @Suppress("ListIterator") + mapNotNull { if (it.predicate()) it.bounds else null } diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt new file mode 100644 index 000000000..056c1e154 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import androidx.window.core.layout.WindowSizeClass +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowMetricsCalculator +import kotlinx.coroutines.flow.map + +@Composable +fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo { + val windowSize = + with(LocalDensity.current) { + currentWindowSize().toSize().toDpSize() + } + return WindowAdaptiveInfo( + WindowSizeClass.compute(windowSize.width.value, windowSize.height.value), + calculatePosture(collectFoldingFeaturesAsState().value), + ) +} + +/** + * Returns and automatically update the current window size from [WindowMetricsCalculator]. + * + * @return an [IntSize] that represents the current window size. + */ +@Composable +fun currentWindowSize(): IntSize { + // Observe view configuration changes and recalculate the size class on each change. We can't + // use Activity#onConfigurationChanged as this will sometimes fail to be called on different + // API levels, hence why this function needs to be @Composable so we can observe the + // ComposeView's configuration changes. + LocalConfiguration.current + val windowBounds = + WindowMetricsCalculator + .getOrCreate() + .computeCurrentWindowMetrics(LocalContext.current) + .bounds + return IntSize(windowBounds.width(), windowBounds.height()) +} + +/** + * Collects the current window folding features from [WindowInfoTracker] in to a [State]. + * + * @return a [State] of a [FoldingFeature] list. + */ +@Composable +fun collectFoldingFeaturesAsState(): State> { + val context = LocalContext.current + return remember(context) { + if (context is Activity) { + // TODO(b/284347941) remove the instance check after the test bug is fixed. + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + } else { + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + }.map { it.displayFeatures.filterIsInstance() } + }.collectAsState(emptyList()) +} + +/** + * This class collects window info that affects adaptation decisions. An adaptive layout is supposed + * to use the info from this class to decide how the layout is supposed to be adapted. + * + * @constructor create an instance of [WindowAdaptiveInfo] + * @param windowSizeClass [WindowSizeClass] of the current window. + * @param windowPosture [Posture] of the current window. + */ +@Immutable +class WindowAdaptiveInfo( + val windowSizeClass: WindowSizeClass, + val windowPosture: Posture, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WindowAdaptiveInfo) return false + if (windowSizeClass != other.windowSizeClass) return false + if (windowPosture != other.windowPosture) return false + return true + } + + override fun hashCode(): Int { + var result = windowSizeClass.hashCode() + result = 31 * result + windowPosture.hashCode() + return result + } + + override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 98dd32c5c..61ad27b8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -41,13 +41,16 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -65,6 +68,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.window.DialogWindowProvider import androidx.core.net.toUri +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter @@ -84,6 +89,7 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon @@ -102,7 +108,6 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.utils.DeviceUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -122,18 +127,38 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } - val orientation = LocalConfiguration.current.orientation - val context = LocalView.current.context.getActivity() + val activity = LocalView.current.context.getActivity() - val (sOrientation, isLandscapeMode) = - when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> "Landscape" to true - Configuration.ORIENTATION_PORTRAIT -> "Portrait" to false + val orientation by snapshotFlow { DeviceUtils.getDeviceOrientation() } + .collectAsState(initial = LocalConfiguration.current.orientation) + val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass - else -> "Unknown" to false + val detectedWindowSize = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + when (currentWindowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> "Likely a Normal device in Landscape mode" + WindowHeightSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Landscape" + WindowHeightSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Landscape" + else -> "Unknown device, likely in Landscape" + } + } else { + when (currentWindowSize.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> "Likely a Normal device in Portrait mode" + WindowWidthSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Portrait" + WindowWidthSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Portrait" + else -> "Unknown device, likely in Portrait" + } } + val (windowSize, sOrientation, isLandscapeMode) = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> Triple(detectedWindowSize, "Landscape", true) + Configuration.ORIENTATION_PORTRAIT -> Triple(detectedWindowSize, "Portrait", false) - Log.d("AmethystConf", "Device orientation is: $sOrientation") + else -> Triple(detectedWindowSize, "Unknown orientation(maybe a foldable device?)", false) + } + val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) + + Log.d("AmethystConf", "Device type based on window size is $windowSize, and orientation is: $sOrientation") val contentScale = if (isFiniteHeight) { @@ -168,7 +193,9 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true - DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (!isFoldableOrLarge) { + DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) + } }, accountViewModel = accountViewModel, ) @@ -206,7 +233,7 @@ fun ZoomableContentView( images, onDismiss = { dialogOpen = false - if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) }, accountViewModel, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt index c2b4e3f1a..26e5c9b00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt @@ -18,16 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.amethyst.ui.components.util import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass object DeviceUtils { - fun getDeviceOrientation(context: Context): Int { - val deviceConfiguration = context.resources.configuration - return deviceConfiguration.orientation + fun getDeviceOrientation(): Int { + val config = Resources.getSystem().configuration + return config.orientation } /** @@ -38,11 +44,11 @@ object DeviceUtils { * Credits: Newpipe devs * */ - fun isLandscape(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels + fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels fun changeDeviceOrientation( isInLandscape: Boolean, - context: Activity, + currentActivity: Activity, ) { val newOrientation = if (isInLandscape) { @@ -50,6 +56,29 @@ object DeviceUtils { } else { ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } - context.requestedOrientation = newOrientation + currentActivity.requestedOrientation = newOrientation } + + @Composable + fun windowIsLarge( + isInLandscapeMode: Boolean, + windowSize: WindowSizeClass, + ): Boolean = + remember(windowSize) { + if (isInLandscapeMode) { + when (windowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> false + WindowHeightSizeClass.MEDIUM -> true + WindowHeightSizeClass.EXPANDED -> true + else -> true + } + } else { + when (windowSize.windowWidthSizeClass) { + WindowWidthSizeClass.EXPANDED -> true + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.COMPACT -> false + else -> true + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 328e53412..eed49df3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ zelory = "3.0.1" zoomable = "1.6.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" +windowCoreAndroid = "1.3.0" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -120,6 +121,7 @@ zelory-video-compressor = { group = "id.zelory", name = "compressor", version.re zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } +androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }