feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather than a file browser, with no new data behind it. - Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter draws for large text, for the banner's name and a title page's name where no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use mappings stripped like the other two cuts. - Artwork fades in over its skeleton on Android and iOS, as it already did on desktop cards; art already decoded draws at once. Desktop episode stills and logos fade in too. - Skeletons shimmer: a soft diagonal highlight instead of a pulse. - Desktop tiles lift towards the viewer under the pointer, capped so a lifted tile never reaches its neighbour, and keyboard focus gets a ring of its own. - Desktop shelves share one `ui::shelf`: edges fade into the page and, under the pointer, round buttons page the row along. - Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio) moves out of the facts line into small outlined marks on all three. - Desktop title page and hero buttons use a tonal style beside the accent one, and the player's top bar a dark glass style, instead of egui's stock buttons. Episode numbers leave monospace for the type ramp. - The phone players' top and bottom bars are gradients rather than flat bands, and a double-tap seek is felt as well as seen.
No files matched your search
@@ -84,6 +84,9 @@ 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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -186,6 +189,7 @@ fun PlayerScreen(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val activity = context as? Activity
|
||||
val titleKey = title.key
|
||||
val episode = episodes.getOrNull(index)
|
||||
@@ -506,6 +510,9 @@ fun PlayerScreen(
|
||||
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
||||
else -> 0
|
||||
}
|
||||
// Felt as well as seen: the bubble is under the thumb
|
||||
// that asked for it.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
if (seconds == 0) {
|
||||
host.setPaused(!current.paused)
|
||||
} else {
|
||||
@@ -542,12 +549,15 @@ fun PlayerScreen(
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
// A gradient rather than a band: dark where the text is, and gone
|
||||
// by the time it reaches the picture, so the top of the frame is
|
||||
// dimmed rather than cut off by a grey bar.
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
|
||||
.background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent)))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onMinimize) {
|
||||
@@ -632,7 +642,7 @@ fun PlayerScreen(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
|
||||
) { Text(skip.label) }
|
||||
}
|
||||
|
||||
@@ -667,7 +677,7 @@ fun PlayerScreen(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
.padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp),
|
||||
)
|
||||
|
||||
// Played to the end with nothing starting after it: the last episode,
|
||||
@@ -874,9 +884,9 @@ private fun PlayerControls(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
|
||||
.background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f))))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
.padding(start = 12.dp, end = 12.dp, top = 32.dp, bottom = 4.dp),
|
||||
) {
|
||||
// Where the thumb is while a finger is on it. A seek per touch-move
|
||||
// aborts every outstanding prefetch, so a one-second drag used to issue
|
||||
@@ -1460,3 +1470,9 @@ private fun formatTime(seconds: Double): String {
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
|
||||
else "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's scrims are black whatever the theme: what is under them is a
|
||||
* video frame, not the page, and a light theme's scrim would wash it out.
|
||||
*/
|
||||
private val Scrim = Color.Black
|
||||
@@ -4,6 +4,9 @@ import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -256,3 +259,38 @@ internal fun EpisodeRecord.heading(position: Int): String {
|
||||
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the title is filed under — a genre — as a small rounded label.
|
||||
* The desktop's `widgets::chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreChip(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||
* The desktop's `widgets::spec_badge`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpecBadge(text: String) {
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = ink,
|
||||
modifier = Modifier
|
||||
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
@@ -644,7 +644,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
||||
) {
|
||||
Text(
|
||||
title.displayName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -6,13 +6,15 @@ import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,10 +27,11 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -79,9 +82,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||
*
|
||||
* [fallback] is used only when there is no provider artwork or it fails to
|
||||
* load, which — with metadata off — is the common case rather than the rare
|
||||
* one. Until either answers, the box pulses as a skeleton; when neither has
|
||||
* one. Until either answers, the box shimmers as a skeleton; when neither has
|
||||
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||
* initials when [labelled] is false, for art with the name already over it.
|
||||
*
|
||||
* Art that arrives fades in over the skeleton rather than replacing it in one
|
||||
* frame, which across a grid reads as flicker. Art already decoded is drawn
|
||||
* at once: a tile scrolled back into view has nothing to arrive.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteArtwork(
|
||||
@@ -97,7 +104,7 @@ internal fun RemoteArtwork(
|
||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||
var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
@@ -107,13 +114,35 @@ internal fun RemoteArtwork(
|
||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||
?: ArtworkLoad.Missing
|
||||
}
|
||||
when (val loaded = image) {
|
||||
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
||||
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
|
||||
when (loaded) {
|
||||
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
|
||||
is ArtworkLoad.Loaded -> Image(
|
||||
loaded.image,
|
||||
description,
|
||||
Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the decoded cache already holds for this art, so it skips the fade. */
|
||||
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
|
||||
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
|
||||
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
|
||||
}
|
||||
|
||||
/**
|
||||
* How long art takes to fade in over its skeleton: the desktop's
|
||||
* `motion::ARRIVE`.
|
||||
*/
|
||||
private const val ARRIVE_MS = 240
|
||||
|
||||
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
|
||||
private const val SHIMMER_MS = 1_400
|
||||
|
||||
/**
|
||||
* A title's art across the top of a page: its backdrop, or with none, its
|
||||
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
|
||||
@@ -158,15 +187,17 @@ internal fun RemoteLogo(
|
||||
LaunchedEffect(url, enabled) {
|
||||
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||
}
|
||||
when (val loaded = logo) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
|
||||
when (loaded) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,20 +262,38 @@ private sealed interface ArtworkLoad {
|
||||
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Something being loaded: the surface tint, breathing.
|
||||
* Something being loaded: the surface tint, with a soft highlight sweeping
|
||||
* across it on a diagonal.
|
||||
*
|
||||
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||
* says what is coming and where, and settles the layout before it arrives.
|
||||
* The sweep is measured in the tile's own width, so neighbouring tiles of one
|
||||
* size shimmer in step, as one surface.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = 0.55f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||
label = "skeleton alpha",
|
||||
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 2f,
|
||||
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
|
||||
label = "skeleton sweep",
|
||||
)
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
|
||||
Box(
|
||||
modifier.drawBehind {
|
||||
drawRect(base)
|
||||
val centre = size.width * sweep
|
||||
val half = size.width * 0.6f
|
||||
drawRect(
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Transparent, highlight, Color.Transparent),
|
||||
start = Offset(centre - half, 0f),
|
||||
end = Offset(centre + half, size.height),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -207,7 +208,7 @@ internal fun TitleScreen(
|
||||
item(key = "heading") {
|
||||
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||
val heading = @Composable {
|
||||
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||
Text(title.displayName, style = MaterialTheme.typography.displayLarge)
|
||||
}
|
||||
// A light page keeps the name: a clear logo is lettering
|
||||
// made for dark art, white as often as not.
|
||||
@@ -237,7 +238,7 @@ internal fun TitleScreen(
|
||||
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||
).plus(title.quality).joinToString(" · "),
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
@@ -256,6 +257,12 @@ internal fun TitleScreen(
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
// What the files are, rather than what the title is.
|
||||
if (title.quality.isNotEmpty()) {
|
||||
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
title.quality.forEach { SpecBadge(it) }
|
||||
}
|
||||
}
|
||||
// The one thing the page is for, full width and naming what it
|
||||
// will play: "Resume" alone left the viewer guessing which
|
||||
// episode of forty it meant.
|
||||
@@ -323,12 +330,21 @@ internal fun TitleScreen(
|
||||
// Genres as chips, then the provider's tags quieter after
|
||||
// them: a tag is its community describing it, not what it
|
||||
// is filed as.
|
||||
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
|
||||
if (title.genres.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
title.genres.forEach { GenreChip(it) }
|
||||
}
|
||||
}
|
||||
if (title.tags.isNotEmpty()) {
|
||||
Text(
|
||||
(title.genres + title.tags.take(4)).joinToString(" · "),
|
||||
title.tags.take(4).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
// Said plainly, because it changes what a re-match will do: a
|
||||
|
||||
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
|
||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
/**
|
||||
* Inter Display Bold, the optical size drawn for large text, for the one
|
||||
* [HERO] line: the banner's name and the name at the top of a title's page.
|
||||
* The desktop's `theme::display` family, from the same file.
|
||||
*/
|
||||
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
|
||||
|
||||
/**
|
||||
* The type ramp, which is the desktop client's type ramp.
|
||||
*
|
||||
@@ -33,7 +40,10 @@ internal val Inter = FontFamily(
|
||||
* were built to share one palette; sharing one scale is the other half of why
|
||||
* they should read as one app.
|
||||
*
|
||||
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The top one is the hero line, in [InterDisplay] and set tighter, at
|
||||
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
|
||||
* apart.
|
||||
*
|
||||
* Where a role maps is decided by what actually draws with it here, not by
|
||||
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||
@@ -44,7 +54,8 @@ internal val Inter = FontFamily(
|
||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||
*/
|
||||
internal val ProtonTypography = Typography(
|
||||
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
// The banner's name and the title page's name, where no logo stands in.
|
||||
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
|
||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||
// The name of the title you opened — the desktop's display line.
|
||||
@@ -69,6 +80,7 @@ internal val ProtonTypography = Typography(
|
||||
labelSmall = role(MICRO, FontWeight.Medium),
|
||||
)
|
||||
|
||||
internal const val HERO = 34f
|
||||
internal const val DISPLAY = 26f
|
||||
internal const val TITLE = 20f
|
||||
internal const val HEADING = 18f
|
||||
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
|
||||
internal const val CAPTION = 12f
|
||||
internal const val MICRO = 11f
|
||||
|
||||
internal const val LEADING = 1.35f
|
||||
internal const val HERO_LEADING = 1.1f
|
||||
|
||||
/**
|
||||
* One rung as a `TextStyle`.
|
||||
*
|
||||
@@ -87,9 +102,14 @@ internal const val MICRO = 11f
|
||||
* enough for the two-line title names in the grid and tight enough that a
|
||||
* label on a control does not float.
|
||||
*/
|
||||
private fun role(size: Float, weight: FontWeight) = TextStyle(
|
||||
private fun role(
|
||||
size: Float,
|
||||
weight: FontWeight,
|
||||
family: FontFamily = Inter,
|
||||
leading: Float = LEADING,
|
||||
) = TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = (size * 1.35f).sp,
|
||||
lineHeight = (size * leading).sp,
|
||||
fontWeight = weight,
|
||||
fontFamily = Inter,
|
||||
fontFamily = family,
|
||||
)
|
||||
@@ -22,10 +22,10 @@ import org.junit.Test
|
||||
*/
|
||||
class TypeRampTest {
|
||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 26f,
|
||||
"displayLarge" to 34f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
@@ -62,12 +62,14 @@ class TypeRampTest {
|
||||
/**
|
||||
* Leading is derived from the size, so the only thing worth asserting is
|
||||
* that it *was* derived — a style that kept Material's line height carries
|
||||
* a number with no relation to its own size.
|
||||
* a number with no relation to its own size. The hero line is the one set
|
||||
* tighter.
|
||||
*/
|
||||
@Test
|
||||
fun `line height follows the size it belongs to`() {
|
||||
styles().forEach { (name, style) ->
|
||||
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
||||
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
|
||||
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 672 KiB After Width: | Height: | Size: 671 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 805 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |