diff --git a/compose/ui/ui-unit/api/desktop/ui-unit.api b/compose/ui/ui-unit/api/desktop/ui-unit.api index 2958da4e32a28..cb6b31f51a9ab 100644 --- a/compose/ui/ui-unit/api/desktop/ui-unit.api +++ b/compose/ui/ui-unit/api/desktop/ui-unit.api @@ -48,7 +48,7 @@ public final class androidx/compose/ui/unit/ConstraintsKt { public static synthetic fun offset-NN6Ew-U$default (JIIILjava/lang/Object;)J } -public abstract interface class androidx/compose/ui/unit/Density : androidx/compose/ui/unit/FontScalingLinear { +public abstract interface class androidx/compose/ui/unit/Density : androidx/compose/ui/unit/FontScaling { public abstract fun getDensity ()F public fun roundToPx--R2X_6o (J)I public fun roundToPx-0680j_4 (F)I @@ -233,6 +233,17 @@ public final class androidx/compose/ui/unit/DpSize$Companion { public abstract interface annotation class androidx/compose/ui/unit/ExperimentalUnitApi : java/lang/annotation/Annotation { } +public abstract interface class androidx/compose/ui/unit/FontScaling { + public abstract fun getFontScale ()F + public fun toDp-GaN1DYA (J)F + public fun toSp-0xMU5do (F)J +} + +public final class androidx/compose/ui/unit/FontScaling$DefaultImpls { + public static fun toDp-GaN1DYA (Landroidx/compose/ui/unit/FontScaling;J)F + public static fun toSp-0xMU5do (Landroidx/compose/ui/unit/FontScaling;F)J +} + public abstract interface class androidx/compose/ui/unit/FontScalingLinear { public abstract fun getFontScale ()F public fun toDp-GaN1DYA (J)F diff --git a/compose/ui/ui-unit/build.gradle b/compose/ui/ui-unit/build.gradle index 115b3e69faa3a..1187e91f44d69 100644 --- a/compose/ui/ui-unit/build.gradle +++ b/compose/ui/ui-unit/build.gradle @@ -78,6 +78,7 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { api(project(":compose:ui:ui-geometry")) implementation(project(":annotation:annotation")) + implementation(project(":collection:collection")) implementation(project(":compose:runtime:runtime")) implementation(project(":compose:ui:ui-util")) } @@ -88,18 +89,26 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) { api("androidx.annotation:annotation:1.1.0") } - jbMain.dependsOn(commonMain) + jbMain { + dependsOn(commonMain) + dependencies { + implementation(libs.atomicFu) + } + } desktopMain.dependsOn(jbMain) jsNativeMain.dependsOn(jbMain) jsMain.dependsOn(jsNativeMain) nativeMain.dependsOn(jsNativeMain) + jsWasmMain.dependsOn(jbMain) jsMain { dependsOn(jsNativeMain) + dependsOn(jsWasmMain) } wasmJsMain { dependsOn(jsNativeMain) + dependsOn(jsWasmMain) dependencies { implementation(libs.kotlinStdlib) } diff --git a/compose/ui/ui-unit/src/desktopMain/kotlin/androidx/compose/ui/unit/FontScaling.desktop.kt b/compose/ui/ui-unit/src/desktopMain/kotlin/androidx/compose/ui/unit/FontScaling.desktop.kt new file mode 100644 index 0000000000000..37c1a62cb5b54 --- /dev/null +++ b/compose/ui/ui-unit/src/desktopMain/kotlin/androidx/compose/ui/unit/FontScaling.desktop.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.unit.fontscaling.FontScaleConverter + +internal actual fun isNonLinearFontScalingActive(fontScale: Float): Boolean = false + +internal actual fun defaultFontScaleConverters() : Map = emptyMap() + +internal actual val NonLinearFontSizeAnchors : List get() = emptyList() diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/Density.jb.kt b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/Density.jb.kt new file mode 100644 index 0000000000000..e07dbd9bd40bd --- /dev/null +++ b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/Density.jb.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.unit.fontscaling.FontScaleConverter + +internal data class DensityWithConverter( + override val density: Float, + override val fontScale: Float, + private val converter: FontScaleConverter +) : Density { + + override fun Dp.toSp(): TextUnit { + return converter.convertDpToSp(value).sp + } + + override fun TextUnit.toDp(): Dp { + check(type == TextUnitType.Sp) { "Only Sp can convert to Px" } + return Dp(converter.convertSpToDp(value)) + } +} + +internal data class LinearFontScaleConverter(private val fontScale: Float) : FontScaleConverter { + override fun convertSpToDp(sp: Float) = sp * fontScale + + override fun convertDpToSp(dp: Float) = dp / fontScale +} diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScaling.jb.kt b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScaling.jb.kt new file mode 100644 index 0000000000000..aea5aa48b64b5 --- /dev/null +++ b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScaling.jb.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.fontscaling.FontScaleConverter +import androidx.compose.ui.unit.fontscaling.FontScaleConverterFactory +import androidx.compose.ui.unit.internal.JvmDefaultWithCompatibility + +/** + * Converts [TextUnit] to [Dp] and vice-versa. + * + * Note that the converter can't be cached in the interface itself. FontScaleConverterFactory + * already caches the tables, but it still does a a map lookup for each conversion. If you are + * implementing this interface, you should cache your own converter for additional speed. + */ +@Immutable +@JvmDefaultWithCompatibility +actual interface FontScaling { + + /** Current user preference for the scaling factor for fonts. */ + @Stable actual val fontScale: Float + + /** Convert [Dp] to Sp. Sp is used for font size, etc. */ + @Stable + actual fun Dp.toSp(): TextUnit { + if (!isNonLinearFontScalingActive(fontScale)) { + return (value / fontScale).sp + } + + val converter = FontScaleConverterFactory.forScale(fontScale) + return (converter?.convertDpToSp(value) ?: (value / fontScale)).sp + } + + /** + * Convert Sp to [Dp]. + * + * @throws IllegalStateException if TextUnit other than SP unit is specified. + */ + @Stable + actual fun TextUnit.toDp(): Dp { + checkPrecondition(type == TextUnitType.Sp) { "Only Sp can convert to Px" } + if (!isNonLinearFontScalingActive(fontScale)) { + return Dp(value * fontScale) + } + + val converter = FontScaleConverterFactory.forScale(fontScale) + ?: return Dp(value * fontScale) + + return Dp(converter.convertSpToDp(value)) + } +} + +/** + * Returns true if non-linear font scaling curves would be in effect for the given scale, false + * if the scaling would follow a linear curve or for no scaling. + */ +internal expect fun isNonLinearFontScalingActive(fontScale: Float): Boolean + +internal expect val NonLinearFontSizeAnchors : List + +internal expect fun defaultFontScaleConverters() : Map \ No newline at end of file diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverter.jb.kt b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverter.jb.kt new file mode 100644 index 0000000000000..54f3a42bc494e --- /dev/null +++ b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverter.jb.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.compose.ui.unit.fontscaling + + +/** + * A converter for non-linear font scaling. Converts font sizes given in "sp" dimensions to a "dp" + * dimension according to a non-linear curve. + * + * This is meant to improve readability at larger font scales: larger fonts will scale up more + * slowly than smaller fonts, so we don't get ridiculously huge fonts that don't fit on the screen. + * + * The thinking here is that large fonts are already big enough to read, but we still want to scale + * them slightly to preserve the visual hierarchy when compared to smaller fonts. + */ +internal interface FontScaleConverter { + /** Converts a dimension in "sp" to "dp". */ + fun convertSpToDp(sp: Float): Float + + /** Converts a dimension in "dp" back to "sp". */ + fun convertDpToSp(dp: Float): Float +} diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.jb.kt b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.jb.kt new file mode 100644 index 0000000000000..05ed0711fd249 --- /dev/null +++ b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.jb.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.compose.ui.unit.fontscaling + +import androidx.collection.SparseArrayCompat +import androidx.collection.forEach +import androidx.collection.size +import androidx.compose.ui.unit.NonLinearFontSizeAnchors +import androidx.compose.ui.unit.defaultFontScaleConverters +import androidx.compose.ui.unit.isNonLinearFontScalingActive +import androidx.compose.ui.util.lerp +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock + +internal object FontScaleConverterFactory { + + private const val ScaleKeyMultiplier = 100f + + private val lookupTablesWriteLock = reentrantLock() + + private var lookupTables = SparseArrayCompat().apply { + lookupTablesWriteLock.withLock { + defaultFontScaleConverters().forEach { (scale, converter) -> + put(getKey(scale), converter) + } + } + } + + /** + * Finds a matching FontScaleConverter for the given fontScale factor. + */ + fun forScale(fontScale: Float): FontScaleConverter? { + if (!isNonLinearFontScalingActive(fontScale)) { + return null + } + + val index = lookupTables.indexOfKey(getKey(fontScale)) + if (index >= 0) { + return lookupTables.valueAt(index) + } + // Didn't find an exact match: interpolate between two existing tables + val lowerIndex = -(index + 1) - 1 + val higherIndex = lowerIndex + 1 + val converter = if (higherIndex >= lookupTables.size()) { + // We have gone beyond our bounds and have nothing to interpolate between. + // Just give them a straight linear table instead. + // This works because when FontScaleConverter encounters a size beyond its bounds, it + // calculates a linear fontScale factor using the ratio of the last element pair. + FontScaleConverterTable(listOf(1f), listOf(fontScale)) + } else { + val (startScale, startTable) = if (lowerIndex < 0) { + // if we're in between 1x and the first table, interpolate between them. + // (See b/336720383) + 1f to FontScaleConverterTable(NonLinearFontSizeAnchors, NonLinearFontSizeAnchors) + } else { + getScaleFromKey(lookupTables.keyAt(lowerIndex)) to lookupTables.valueAt(lowerIndex) + } + + val endScale = getScaleFromKey(lookupTables.keyAt(higherIndex)) + + createInterpolatedTableBetween( + startTable, + lookupTables.valueAt(higherIndex), + constrainedMap(0f, 1f, startScale, endScale, fontScale) + ) + } + + put(fontScale, converter) + + return converter + } + + private fun createInterpolatedTableBetween( + start: FontScaleConverter, + end: FontScaleConverter, + interpolationPoint: Float + ): FontScaleConverter = FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = NonLinearFontSizeAnchors.map { sp -> + lerp(start.convertSpToDp(sp), end.convertSpToDp(sp), interpolationPoint) + } + ) + + + private fun getKey(fontScale: Float): Int { + return (fontScale * ScaleKeyMultiplier).toInt() + } + + private fun getScaleFromKey(key: Int): Float { + return key.toFloat() / ScaleKeyMultiplier + } + + private fun put(scaleKey: Float, fontScaleConverter: FontScaleConverter) { + lookupTablesWriteLock.withLock { + // copy-on-write to safely omit reading synchronization + val copy = SparseArrayCompat(lookupTables.size + 1) + lookupTables.forEach { key, value -> + copy.put(key, value) + } + copy.put(getKey(scaleKey), fontScaleConverter) + lookupTables = copy + } + } +} \ No newline at end of file diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterTable.jb.kt b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterTable.jb.kt new file mode 100644 index 0000000000000..ad4c104239a10 --- /dev/null +++ b/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterTable.jb.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.compose.ui.unit.fontscaling + +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import kotlin.math.absoluteValue +import kotlin.math.sign + +/** + * A lookup table for non-linear font scaling. Converts font sizes given in "sp" dimensions to a + * "dp" dimension according to a non-linear curve by interpolating values in a lookup table. + */ +internal class FontScaleConverterTable( + val fromSp: List, + val toDp: List +) : FontScaleConverter { + + init { + require(!(fromSp.size != toDp.size || fromSp.isEmpty())) { + "Array lengths must match and be nonzero" + } + } + + + override fun convertDpToSp(dp: Float): Float { + return lookupAndInterpolate(dp, toDp, fromSp) + } + + override fun convertSpToDp(sp: Float): Float { + return lookupAndInterpolate(sp, fromSp, toDp) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as FontScaleConverterTable + + if (fromSp != other.fromSp) return false + if (toDp != other.toDp) return false + + return true + } + + override fun hashCode(): Int { + var result = fromSp.hashCode() + result = 31 * result + toDp.hashCode() + return result + } + + companion object { + private fun lookupAndInterpolate( + sourceValue: Float, + sourceValues: List, + targetValues: List + ): Float { + val sourceValuePositive = sourceValue.absoluteValue + // TODO(b/247861374): find a match at a higher index? + val sign = sign(sourceValue) + // We search for exact matches only, even if it's just a little off. The interpolation + // will + // handle any non-exact matches. + val index = sourceValues.binarySearch { it.compareTo(sourceValuePositive) } + return if (index >= 0) { + // exact match, return the matching dp + sign * targetValues[index] + } else { + // must be a value in between index and index + 1: interpolate. + val lowerIndex = -(index + 1) - 1 + val startSp: Float + val endSp: Float + val startDp: Float + val endDp: Float + if (lowerIndex >= sourceValues.size - 1) { + // It's past our lookup table. Determine the last elements' scaling factor and + // use. + startSp = sourceValues[sourceValues.size - 1] + startDp = targetValues[sourceValues.size - 1] + if (startSp == 0f) return 0f + val scalingFactor = startDp / startSp + return sourceValue * scalingFactor + } else if (lowerIndex == -1) { + // It's smaller than the smallest value in our table. Interpolate from 0. + startSp = 0f + startDp = 0f + endSp = sourceValues[0] + endDp = targetValues[0] + } else { + startSp = sourceValues[lowerIndex] + endSp = sourceValues[lowerIndex + 1] + startDp = targetValues[lowerIndex] + endDp = targetValues[lowerIndex + 1] + } + sign * constrainedMap( + startDp, + endDp, + startSp, + endSp, + sourceValuePositive + ) + } + } + } +} + +/** + * Inverse of [lerp]. More precisely, returns the interpolation scalar (s) that satisfies the + * equation: `value = `[lerp]`(a, b, s)` + * + * If `a == b`, then this function will return 0. + */ +private fun lerpInv(a: Float, b: Float, value: Float): Float { + return if (a != b) (value - a) / (b - a) else 0.0f +} + +/** + * Calculates a value in [rangeMin, rangeMax] that maps value in [valueMin, valueMax] to + * returnVal in [rangeMin, rangeMax]. + * + * Always returns a constrained value in the range [rangeMin, rangeMax], even if value is + * outside [valueMin, valueMax]. + * + * Eg: constrainedMap(0f, 100f, 0f, 1f, 0.5f) = 50f constrainedMap(20f, 200f, 10f, 20f, 20f) = + * 200f constrainedMap(20f, 200f, 10f, 20f, 50f) = 200f constrainedMap(10f, 50f, 10f, 20f, 5f) = + * 10f + * + * @param rangeMin minimum of the range that should be returned. + * @param rangeMax maximum of the range that should be returned. + * @param valueMin minimum of range to map `value` to. + * @param valueMax maximum of range to map `value` to. + * @param value to map to the range [`valueMin`, `valueMax`]. Note, can be outside this range, + * resulting in a clamped value. + * @return the mapped value, constrained to [`rangeMin`, `rangeMax`]. + */ +internal fun constrainedMap( + rangeMin: Float, + rangeMax: Float, + valueMin: Float, + valueMax: Float, + value: Float +): Float { + return lerp( + rangeMin, + rangeMax, + lerpInv(valueMin, valueMax, value).fastCoerceIn(0f, 1f) + ) +} diff --git a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScacling.jb.kt b/compose/ui/ui-unit/src/jsWasmMain/kotlin/androidx/compose/ui/unit/FontScaling.jsWasm.kt similarity index 61% rename from compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScacling.jb.kt rename to compose/ui/ui-unit/src/jsWasmMain/kotlin/androidx/compose/ui/unit/FontScaling.jsWasm.kt index ccdc4e64a8b57..6e6279e8b8c64 100644 --- a/compose/ui/ui-unit/src/jbMain/kotlin/androidx/compose/ui/unit/FontScacling.jb.kt +++ b/compose/ui/ui-unit/src/jsWasmMain/kotlin/androidx/compose/ui/unit/FontScaling.jsWasm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Android Open Source Project + * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,10 @@ package androidx.compose.ui.unit -/** - * Converts [TextUnit] to [Dp] and vice-versa. - */ -actual typealias FontScaling = FontScalingLinear +import androidx.compose.ui.unit.fontscaling.FontScaleConverter + +internal actual fun isNonLinearFontScalingActive(fontScale: Float): Boolean = false + +internal actual val NonLinearFontSizeAnchors : List get() = emptyList() + +internal actual fun defaultFontScaleConverters() : Map = emptyMap() \ No newline at end of file diff --git a/compose/ui/ui-unit/src/macosMain/kotlin/androidx/compose/ui/unit/FontScaling.macos.kt b/compose/ui/ui-unit/src/macosMain/kotlin/androidx/compose/ui/unit/FontScaling.macos.kt new file mode 100644 index 0000000000000..cb2801634a543 --- /dev/null +++ b/compose/ui/ui-unit/src/macosMain/kotlin/androidx/compose/ui/unit/FontScaling.macos.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.unit.fontscaling.FontScaleConverter + +internal actual fun isNonLinearFontScalingActive(fontScale: Float): Boolean = false + +internal actual val NonLinearFontSizeAnchors : List get() = emptyList() + +internal actual fun defaultFontScaleConverters() : Map = emptyMap() diff --git a/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/FontScaling.uikit.kt b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/FontScaling.uikit.kt new file mode 100644 index 0000000000000..d5c8dd96f5cc6 --- /dev/null +++ b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/FontScaling.uikit.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.unit.fontscaling.FontScaleConverter +import androidx.compose.ui.unit.fontscaling.FontScaleConverterTable + +internal actual fun isNonLinearFontScalingActive(fontScale: Float): Boolean { + return fontScale >= UIKitContentSize.ExtraSmall.fontScale + && fontScale !in 0.97f..1.03f // scale range resulting close to linear + // isn't worth the computational overhead +} + +internal actual val NonLinearFontSizeAnchors : List = + listOf(11f, 12f, 13f, 15f, 16f, 17f, 20f, 22f, 28f, 34f, 100f) + +// https://developer.apple.com/design/human-interface-guidelines/typography#iOS-iPadOS-Dynamic-Type-sizes +internal actual fun defaultFontScaleConverters() : Map = mapOf( + UIKitContentSize.ExtraSmall.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(11f, 11f, 12f, 12f, 13f, 14f, 17f, 19f, 25f, 31f, 100f) + ), + UIKitContentSize.Small.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(11f, 11f, 12f, 13f, 14f, 15f, 18f, 20f, 26f, 32f, 100f) + ), + UIKitContentSize.Medium.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(11f, 11f, 12f, 14f, 15f, 16f, 19f, 21f, 27f, 33f, 100f) + ), + UIKitContentSize.Large.fontScale to LinearFontScaleConverter(1f), + UIKitContentSize.XL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(13f, 14f, 15f, 17f, 18f, 19f, 22f, 24f, 30f, 36f, 100f) + ), + UIKitContentSize.XXL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(15f, 16f, 17f, 19f, 20f, 21f, 24f, 26f, 32f, 38f, 100f) + ), + UIKitContentSize.XXXL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(17f, 18f, 19f, 21f, 22f, 23f, 26f, 28f, 34f, 40f, 100f) + ), + UIKitContentSize.AccessibleMedium.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(20f, 22f, 23f, 25f, 26f, 28f, 31f, 34f, 38f, 44f, 100f) + ), + UIKitContentSize.AccessibleLarge.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(24f, 26f, 27f, 30f, 32f, 33f, 37f, 39f, 43f, 48f, 100f) + ), + UIKitContentSize.AccessibleXL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(29f, 32f, 33f, 36f, 38f, 40f, 43f, 44f, 48f, 52f, 100f) + ), + UIKitContentSize.AccessibleXXL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(34f, 37f, 38f, 42f, 44f, 47f, 49f, 50f, 53f, 56f, 100f) + ), + UIKitContentSize.AccessibleXXXL.fontScale to FontScaleConverterTable( + fromSp = NonLinearFontSizeAnchors, + toDp = listOf(40f, 43f, 44f, 49f, 51f, 53f, 55f, 56f, 58f, 60f, 100f) + ) +) \ No newline at end of file diff --git a/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitContentSize.uikit.kt b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitContentSize.uikit.kt new file mode 100644 index 0000000000000..6b0021342620b --- /dev/null +++ b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitContentSize.uikit.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import platform.UIKit.UIContentSizeCategory +import platform.UIKit.UIContentSizeCategoryExtraSmall +import platform.UIKit.UIContentSizeCategorySmall +import platform.UIKit.UIContentSizeCategoryMedium +import platform.UIKit.UIContentSizeCategoryLarge +import platform.UIKit.UIContentSizeCategoryExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityMedium +import platform.UIKit.UIContentSizeCategoryAccessibilityLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraExtraLarge + +internal value class UIKitContentSize(val fontScale : Float) { + + companion object { + + val Default get() = Large + + val ExtraSmall = UIKitContentSize(0.8f) + val Small = UIKitContentSize(0.85f) + val Medium = UIKitContentSize(0.9f) + val Large = UIKitContentSize(1f) + val XL = UIKitContentSize(1.1f) + val XXL = UIKitContentSize(1.2f) + val XXXL = UIKitContentSize(1.3f) + val AccessibleMedium = UIKitContentSize(1.4f) + val AccessibleLarge = UIKitContentSize(1.5f) + val AccessibleXL = UIKitContentSize(1.6f) + val AccessibleXXL = UIKitContentSize(1.7f) + val AccessibleXXXL = UIKitContentSize(1.8f) + + fun fromNative(size: UIContentSizeCategory): UIKitContentSize { + return when (size) { + UIContentSizeCategoryExtraSmall -> ExtraSmall + UIContentSizeCategorySmall -> Small + UIContentSizeCategoryMedium -> Medium + UIContentSizeCategoryLarge -> Large + UIContentSizeCategoryExtraLarge -> XL + UIContentSizeCategoryExtraExtraLarge -> XXL + UIContentSizeCategoryExtraExtraExtraLarge -> XXXL + UIContentSizeCategoryAccessibilityMedium -> AccessibleMedium + UIContentSizeCategoryAccessibilityLarge -> AccessibleLarge + UIContentSizeCategoryAccessibilityExtraLarge -> AccessibleXL + UIContentSizeCategoryAccessibilityExtraExtraLarge -> AccessibleXXL + UIContentSizeCategoryAccessibilityExtraExtraExtraLarge -> AccessibleXXXL + else -> Default + } + } + } +} \ No newline at end of file diff --git a/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitDensity.uikit.kt b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitDensity.uikit.kt new file mode 100644 index 0000000000000..51890b1ed33e6 --- /dev/null +++ b/compose/ui/ui-unit/src/uikitMain/kotlin/androidx/compose/ui/unit/UIKitDensity.uikit.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.unit.fontscaling.FontScaleConverterFactory +import platform.UIKit.UIContentSizeCategoryUnspecified +import platform.UIKit.UIScreen +import platform.UIKit.UIView +import platform.UIKit.UIWindow + +@InternalComposeUiApi +fun Density(view : UIView): Density { + + val screen : UIScreen = if (view is UIWindow) { + view.screen + } else { + view.window?.screen ?: UIScreen.mainScreen + } + + val contentSizeCategory = view.window?.traitCollection + ?.preferredContentSizeCategory ?: UIContentSizeCategoryUnspecified + + val fontScale = UIKitContentSize.fromNative(contentSizeCategory).fontScale + + return DensityWithConverter( + density = screen.scale.toFloat(), + fontScale = fontScale, + converter = FontScaleConverterFactory.forScale(fontScale) + ?: LinearFontScaleConverter(fontScale) + ) +} + diff --git a/compose/ui/ui-unit/src/uikitTest/kotlin/androidx/compose/ui/unit/FontScalingTest.uikit.kt b/compose/ui/ui-unit/src/uikitTest/kotlin/androidx/compose/ui/unit/FontScalingTest.uikit.kt new file mode 100644 index 0000000000000..53a83c438d33c --- /dev/null +++ b/compose/ui/ui-unit/src/uikitTest/kotlin/androidx/compose/ui/unit/FontScalingTest.uikit.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.unit + +import androidx.compose.ui.unit.fontscaling.FontScaleConverterFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FontScalingTest { + + @Test + fun sp_to_dp_test() { + + val density: Density = DensityWithConverter( + density = 1f, + fontScale = UIKitContentSize.XXXL.fontScale, + converter = assertNotNull( + FontScaleConverterFactory + .forScale(UIKitContentSize.XXXL.fontScale) + ) + ) + + with(density) { + + // default + assertEquals(17f, 11.sp.toDp().value) + assertEquals(18f, 12.sp.toDp().value) + assertEquals(19f, 13.sp.toDp().value) + assertEquals(21f, 15.sp.toDp().value) + assertEquals(22f, 16.sp.toDp().value) + assertEquals(23f, 17.sp.toDp().value) + assertEquals(26f, 20.sp.toDp().value) + assertEquals(28f, 22.sp.toDp().value) + assertEquals(34f, 28.sp.toDp().value) + assertEquals(40f, 34.sp.toDp().value) + + // interpolated + assertEquals(17.5f, 11.5f.sp.toDp().value) + assertEquals(31f, 25.sp.toDp().value) + } + } + + @Test + fun interpolated_converter() { + + val scale = androidx.compose.ui.util.lerp( + UIKitContentSize.XL.fontScale, + UIKitContentSize.XXL.fontScale, + .5f + ) + + val converter = assertNotNull(FontScaleConverterFactory.forScale(scale)) + + assertTrue("interpolated converter should be cached") { + converter === FontScaleConverterFactory.forScale(scale) + } + + val density: Density = DensityWithConverter( + density = 1f, + fontScale = scale, + converter = converter + ) + + val tolerance = 1e-4f + + with(density) { + + assertEquals(14f, 11.sp.toDp().value, tolerance) + assertEquals(15f, 12.sp.toDp().value, tolerance) + assertEquals(16f, 13.sp.toDp().value, tolerance) + assertEquals(18f, 15.sp.toDp().value, tolerance) + assertEquals(19f, 16.sp.toDp().value, tolerance) + assertEquals(20f, 17.sp.toDp().value, tolerance) + assertEquals(23f, 20.sp.toDp().value, tolerance) + assertEquals(25f, 22.sp.toDp().value, tolerance) + assertEquals(31f, 28.sp.toDp().value, tolerance) + assertEquals(37f, 34.sp.toDp().value, tolerance) + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt index 2320a324e4293..47ef23d5105ed 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt @@ -18,69 +18,14 @@ package androidx.compose.ui.uikit import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpRect -import androidx.compose.ui.unit.dp import kotlin.math.floor import kotlin.math.roundToLong -import kotlinx.cinterop.CValue -import kotlinx.cinterop.useContents -import platform.CoreGraphics.CGRect import platform.Foundation.NSTimeInterval import platform.UIKit.UIColor -import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraExtraLarge -import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraLarge -import platform.UIKit.UIContentSizeCategoryAccessibilityExtraLarge -import platform.UIKit.UIContentSizeCategoryAccessibilityLarge -import platform.UIKit.UIContentSizeCategoryAccessibilityMedium -import platform.UIKit.UIContentSizeCategoryExtraExtraExtraLarge -import platform.UIKit.UIContentSizeCategoryExtraExtraLarge -import platform.UIKit.UIContentSizeCategoryExtraLarge -import platform.UIKit.UIContentSizeCategoryExtraSmall -import platform.UIKit.UIContentSizeCategoryLarge -import platform.UIKit.UIContentSizeCategoryMedium -import platform.UIKit.UIContentSizeCategorySmall -import platform.UIKit.UIContentSizeCategoryUnspecified -import platform.UIKit.UIScreen import platform.UIKit.UIView -import platform.UIKit.UIWindow internal val UIView.density: Density - get() { - // TODO: It's a code smell that we have to retrive a default UIScreen here. - // We probably should reorder the code so that density is either injected from outside - // or view is attached to a window before this is called. - val screen = if (this is UIWindow) { - screen - } else { - window?.screen ?: UIScreen.mainScreen - } - - val contentSizeCategory = traitCollection.preferredContentSizeCategory ?: UIContentSizeCategoryUnspecified - - return Density( - density = screen.scale.toFloat(), - fontScale = uiContentSizeCategoryToFontScaleMap[contentSizeCategory] ?: 1.0f - ) - } - -private val uiContentSizeCategoryToFontScaleMap = mapOf( - UIContentSizeCategoryExtraSmall to 0.8f, - UIContentSizeCategorySmall to 0.85f, - UIContentSizeCategoryMedium to 0.9f, - UIContentSizeCategoryLarge to 1f, // default preference - UIContentSizeCategoryExtraLarge to 1.1f, - UIContentSizeCategoryExtraExtraLarge to 1.2f, - UIContentSizeCategoryExtraExtraExtraLarge to 1.3f, - - // These values don't work well if they match scale shown by - // Text Size control hint, because iOS uses non-linear scaling - // calculated by UIFontMetrics, while Compose uses linear. - UIContentSizeCategoryAccessibilityMedium to 1.4f, // 160% native - UIContentSizeCategoryAccessibilityLarge to 1.5f, // 190% native - UIContentSizeCategoryAccessibilityExtraLarge to 1.6f, // 235% native - UIContentSizeCategoryAccessibilityExtraExtraLarge to 1.7f, // 275% native - UIContentSizeCategoryAccessibilityExtraExtraExtraLarge to 1.8f, // 310% native -) + get() = Density(this) internal fun Color.toUIColor(): UIColor? = if (this == Color.Unspecified) {