Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f2879a9

Browse files
author
Chris Banes
committedAug 14, 2020
[Jetcaster] Rework dominant color palette to use MaterialTheme
Currently the generated color is only used within a vertical gradient composable. This CL expands the result and pushes it into the MaterialTheme. Change-Id: I672f5cd9b51ce5ece677da4543655bd363688c01
1 parent eff57d0 commit f2879a9

File tree

10 files changed

+345
-200
lines changed

10 files changed

+345
-200
lines changed
 

‎Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ package com.example.jetcaster.data
2121
* in this sample app.
2222
*/
2323
val SampleFeeds = listOf(
24+
"https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss",
25+
"https://audioboom.com/channels/2399216.rss",
26+
"http://nowinandroid.googledevelopers.libsynpro.com/rss",
27+
"https://fragmentedpodcast.com/feed/",
28+
"https://feeds.megaphone.fm/replyall",
2429
"http://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage",
2530
"https://feeds.thisamericanlife.org/talpodcast",
2631
"https://feeds.npr.org/510289/podcast.xml",

‎Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.example.jetcaster.ui.home
1818

19+
import androidx.compose.foundation.Box
1920
import androidx.compose.foundation.Icon
2021
import androidx.compose.foundation.Text
2122
import androidx.compose.foundation.background
@@ -47,6 +48,7 @@ import androidx.compose.material.icons.filled.Search
4748
import androidx.compose.runtime.Composable
4849
import androidx.compose.runtime.collectAsState
4950
import androidx.compose.runtime.getValue
51+
import androidx.compose.runtime.launchInComposition
5052
import androidx.compose.runtime.remember
5153
import androidx.compose.ui.Alignment
5254
import androidx.compose.ui.Modifier
@@ -65,12 +67,15 @@ import com.example.jetcaster.data.PodcastWithExtraInfo
6567
import com.example.jetcaster.ui.home.discover.Discover
6668
import com.example.jetcaster.ui.theme.JetcasterTheme
6769
import com.example.jetcaster.ui.theme.Keyline1
68-
import com.example.jetcaster.util.DominantColorVerticalGradient
70+
import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage
6971
import com.example.jetcaster.util.Pager
7072
import com.example.jetcaster.util.PagerState
7173
import com.example.jetcaster.util.ToggleFollowPodcastIconButton
74+
import com.example.jetcaster.util.constrastAgainst
7275
import com.example.jetcaster.util.quantityStringResource
76+
import com.example.jetcaster.util.rememberDominantColorState
7377
import com.example.jetcaster.util.statusBarPadding
78+
import com.example.jetcaster.util.verticalGradientScrim
7479
import dev.chrisbanes.accompanist.coil.CoilImage
7580
import java.time.Duration
7681
import java.time.LocalDateTime
@@ -129,6 +134,13 @@ fun HomeAppBar(
129134
}
130135
}
131136

137+
/**
138+
* This is the minimum amount of calculated constrast for a color to be used on top of the
139+
* surface color. These values are defined within the WCAG AA guidelines, and we use a value of
140+
* 3:1 which is the minimum for user-interface components.
141+
*/
142+
private const val MinConstastOfPrimaryVsSurface = 3f
143+
132144
@Composable
133145
fun HomeContent(
134146
featuredPodcasts: List<PodcastWithExtraInfo>,
@@ -140,37 +152,63 @@ fun HomeContent(
140152
onCategorySelected: (HomeCategory) -> Unit
141153
) {
142154
Column(modifier = modifier) {
143-
Stack(Modifier.fillMaxWidth()) {
155+
// We dynamically theme this sub-section of the layout to match the selected
156+
// 'top podcast'
157+
158+
val surfaceColor = MaterialTheme.colors.surface
159+
val dominantColorState = rememberDominantColorState { color ->
160+
// We want a color which has sufficient contrast against the surface color
161+
color.constrastAgainst(surfaceColor) >= MinConstastOfPrimaryVsSurface
162+
}
163+
164+
DynamicThemePrimaryColorsFromImage(dominantColorState) {
144165
val clock = AnimationClockAmbient.current
145166
val pagerState = remember(clock) { PagerState(clock) }
146167

147-
DominantColorVerticalGradient(
148-
imageSourceUrl = featuredPodcasts.getOrNull(pagerState.currentPage)
149-
?.podcast?.imageUrl,
150-
modifier = Modifier.matchParentSize()
151-
)
168+
val selectedImageUrl = featuredPodcasts.getOrNull(pagerState.currentPage)
169+
?.podcast?.imageUrl
152170

153-
Column(Modifier.fillMaxWidth()) {
154-
HomeAppBar(
155-
modifier = Modifier.fillMaxWidth()
156-
.statusBarPadding()
157-
.preferredHeight(56.dp) /* TODO: change height to 48.dp in landscape */
171+
// When the selected image url changes, call updateColorsFromImageUrl() or reset()
172+
if (selectedImageUrl != null) {
173+
launchInComposition(selectedImageUrl) {
174+
dominantColorState.updateColorsFromImageUrl(selectedImageUrl)
175+
}
176+
} else {
177+
dominantColorState.reset()
178+
}
179+
180+
Stack(Modifier.fillMaxWidth()) {
181+
Box(
182+
Modifier.matchParentSize()
183+
.verticalGradientScrim(
184+
color = MaterialTheme.colors.primary.copy(alpha = 0.38f),
185+
startYPercentage = 1f,
186+
endYPercentage = 0f
187+
)
158188
)
159189

160-
if (featuredPodcasts.isNotEmpty()) {
161-
Spacer(Modifier.height(16.dp))
162-
163-
FollowedPodcasts(
164-
items = featuredPodcasts,
165-
pagerState = pagerState,
166-
onPodcastUnfollowed = onPodcastUnfollowed,
167-
modifier = Modifier
168-
.padding(start = Keyline1, top = 16.dp, end = Keyline1)
169-
.fillMaxWidth()
170-
.preferredHeight(200.dp)
190+
Column(Modifier.fillMaxWidth()) {
191+
HomeAppBar(
192+
Modifier.fillMaxWidth()
193+
.statusBarPadding()
194+
.preferredHeight(56.dp) /* TODO: change height to 48.dp in landscape */
171195
)
172196

173-
Spacer(Modifier.height(16.dp))
197+
if (featuredPodcasts.isNotEmpty()) {
198+
Spacer(Modifier.height(16.dp))
199+
200+
FollowedPodcasts(
201+
items = featuredPodcasts,
202+
pagerState = pagerState,
203+
onPodcastUnfollowed = onPodcastUnfollowed,
204+
modifier = Modifier
205+
.padding(start = Keyline1, top = 16.dp, end = Keyline1)
206+
.fillMaxWidth()
207+
.preferredHeight(200.dp)
208+
)
209+
210+
Spacer(Modifier.height(16.dp))
211+
}
174212
}
175213
}
176214
}

‎Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ fun Colors.compositedOnSurface(alpha: Float): Color {
3434
val Yellow800 = Color(0xFFF29F05)
3535
val Red300 = Color(0xFFEA6D7E)
3636

37-
val Colors = darkColors(
37+
val JetcasterColors = darkColors(
3838
primary = Yellow800,
3939
onPrimary = Color.Black,
4040
primaryVariant = Yellow800,

‎Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
2020
import androidx.compose.material.Shapes
2121
import androidx.compose.ui.unit.dp
2222

23-
val Shapes = Shapes(
23+
val JetcasterShapes = Shapes(
2424
small = RoundedCornerShape(percent = 50),
2525
medium = RoundedCornerShape(size = 8.dp),
2626
large = RoundedCornerShape(size = 0.dp)

‎Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ fun JetcasterTheme(
2424
content: @Composable () -> Unit
2525
) {
2626
MaterialTheme(
27-
colors = Colors,
28-
typography = Typography,
29-
shapes = Shapes,
27+
colors = JetcasterColors,
28+
typography = JetcasterTypography,
29+
shapes = JetcasterShapes,
3030
content = content
3131
)
3232
}

‎Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ private val Montserrat = fontFamily(
3131
font(R.font.montserrat_semibold, FontWeight.SemiBold)
3232
)
3333

34-
val Typography = Typography(
34+
val JetcasterTypography = Typography(
3535
h1 = TextStyle(
3636
fontFamily = Montserrat,
3737
fontSize = 96.sp,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2020 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetcaster.util
18+
19+
import androidx.compose.foundation.isSystemInDarkTheme
20+
import androidx.compose.material.Colors
21+
import androidx.compose.material.darkColors
22+
import androidx.compose.material.lightColors
23+
import androidx.compose.runtime.Composable
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.graphics.compositeOver
26+
import androidx.compose.ui.graphics.luminance
27+
import kotlin.math.max
28+
import kotlin.math.min
29+
30+
@Composable
31+
fun Colors.copy(
32+
primary: Color = this.primary,
33+
primaryVariant: Color = this.primaryVariant,
34+
secondary: Color = this.secondary,
35+
secondaryVariant: Color = this.secondaryVariant,
36+
background: Color = this.background,
37+
surface: Color = this.surface,
38+
error: Color = this.error,
39+
onPrimary: Color = this.onPrimary,
40+
onSecondary: Color = this.onSecondary,
41+
onBackground: Color = this.onBackground,
42+
onSurface: Color = this.onSurface,
43+
onError: Color = this.onError,
44+
darkColors: Boolean = isSystemInDarkTheme()
45+
): Colors = if (darkColors) {
46+
darkColors(
47+
primary = primary,
48+
primaryVariant = primaryVariant,
49+
secondary = secondary,
50+
background = background,
51+
surface = surface,
52+
error = error,
53+
onPrimary = onPrimary,
54+
onSecondary = onSecondary,
55+
onBackground = onBackground,
56+
onSurface = onSurface,
57+
onError = onError
58+
)
59+
} else {
60+
lightColors(
61+
primary = primary,
62+
primaryVariant = primaryVariant,
63+
secondary = secondary,
64+
secondaryVariant = secondaryVariant,
65+
background = background,
66+
surface = surface,
67+
error = error,
68+
onPrimary = onPrimary,
69+
onSecondary = onSecondary,
70+
onBackground = onBackground,
71+
onSurface = onSurface,
72+
onError = onError
73+
)
74+
}
75+
76+
fun Color.constrastAgainst(background: Color): Float {
77+
val fg = if (alpha < 1f) compositeOver(background) else this
78+
79+
val fgLuminance = fg.luminance() + 0.05f
80+
val bgLuminance = background.luminance() + 0.05f
81+
82+
return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance)
83+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright 2020 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetcaster.util
18+
19+
import android.content.Context
20+
import androidx.collection.LruCache
21+
import androidx.compose.animation.animate
22+
import androidx.compose.animation.core.Spring
23+
import androidx.compose.animation.core.spring
24+
import androidx.compose.material.MaterialTheme
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.Immutable
27+
import androidx.compose.runtime.Stable
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableStateOf
30+
import androidx.compose.runtime.remember
31+
import androidx.compose.runtime.setValue
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.platform.ContextAmbient
34+
import androidx.core.graphics.drawable.toBitmap
35+
import androidx.palette.graphics.Palette
36+
import coil.Coil
37+
import coil.request.GetRequest
38+
import coil.request.SuccessResult
39+
import coil.size.Scale
40+
import kotlinx.coroutines.Dispatchers
41+
import kotlinx.coroutines.withContext
42+
43+
@Composable
44+
fun rememberDominantColorState(
45+
context: Context = ContextAmbient.current,
46+
defaultColor: Color = MaterialTheme.colors.primary,
47+
defaultOnColor: Color = MaterialTheme.colors.onPrimary,
48+
cacheSize: Int = 12,
49+
isColorValid: (Color) -> Boolean = { true }
50+
): DominantColorState = remember {
51+
DominantColorState(context, defaultColor, defaultOnColor, cacheSize, isColorValid)
52+
}
53+
54+
/**
55+
* A composable which allows dynamic theming of the [androidx.compose.material.Colors.primary]
56+
* color from an image.
57+
*/
58+
@Composable
59+
fun DynamicThemePrimaryColorsFromImage(
60+
dominantColorState: DominantColorState = rememberDominantColorState(),
61+
content: @Composable () -> Unit
62+
) {
63+
val colors = MaterialTheme.colors.copy(
64+
primary = animate(dominantColorState.color, spring(stiffness = Spring.StiffnessLow)),
65+
onPrimary = animate(dominantColorState.onColor, spring(stiffness = Spring.StiffnessLow))
66+
)
67+
MaterialTheme(colors = colors, content = content)
68+
}
69+
70+
/**
71+
* A class which stores and caches the result of any calculated dominant colors
72+
* from images.
73+
*
74+
* @param context Android context
75+
* @param defaultColor The default color, which will be used if [calculateDominantColor] fails to
76+
* calculate a dominant color
77+
* @param defaultOnColor The default foreground 'on color' for [defaultColor].
78+
* @param cacheSize The size of the [LruCache] used to store recent results. Pass `0` to
79+
* disable the cache.
80+
* @param isColorValid A lambda which allows filtering of the calculated image colors.
81+
*/
82+
@Stable
83+
class DominantColorState(
84+
private val context: Context,
85+
private val defaultColor: Color,
86+
private val defaultOnColor: Color,
87+
cacheSize: Int = 12,
88+
private val isColorValid: (Color) -> Boolean = { true }
89+
) {
90+
var color by mutableStateOf(defaultColor)
91+
private set
92+
var onColor by mutableStateOf(defaultOnColor)
93+
private set
94+
95+
private val cache = when {
96+
cacheSize > 0 -> LruCache<String, DominantColors>(cacheSize)
97+
else -> null
98+
}
99+
100+
suspend fun updateColorsFromImageUrl(url: String) {
101+
val result = calculateDominantColor(url)
102+
color = result?.color ?: defaultColor
103+
onColor = result?.onColor ?: defaultOnColor
104+
}
105+
106+
private suspend fun calculateDominantColor(url: String): DominantColors? {
107+
val cached = cache?.get(url)
108+
if (cached != null) {
109+
// If we already have the result cached, return early now...
110+
return cached
111+
}
112+
113+
// Otherwise we calculate the swatches in the image, and return the first valid color
114+
return calculateSwatchesInImage(context, url)
115+
// First we want to sort the list by the color's population
116+
.sortedByDescending { swatch -> swatch.population }
117+
// Then we want to find the first valid color
118+
.firstOrNull { swatch -> isColorValid(Color(swatch.rgb)) }
119+
// If we found a valid swatch, wrap it in a [DominantColors]
120+
?.let { swatch ->
121+
DominantColors(
122+
color = Color(swatch.rgb),
123+
onColor = Color(swatch.bodyTextColor).copy(alpha = 1f)
124+
)
125+
}
126+
// Cache the resulting [DominantColors]
127+
?.also { result -> cache?.put(url, result) }
128+
}
129+
130+
/**
131+
* Reset the color values to [defaultColor].
132+
*/
133+
fun reset() {
134+
color = defaultColor
135+
onColor = defaultColor
136+
}
137+
}
138+
139+
@Immutable
140+
private data class DominantColors(val color: Color, val onColor: Color)
141+
142+
/**
143+
* Fetches the given [imageUrl] with [Coil], then uses [Palette] to calculate the dominant color.
144+
*/
145+
private suspend fun calculateSwatchesInImage(
146+
context: Context,
147+
imageUrl: String
148+
): List<Palette.Swatch> {
149+
val r = GetRequest.Builder(context)
150+
.data(imageUrl)
151+
// We scale the image to cover 128px x 128px (i.e. min dimension == 128px)
152+
.size(128).scale(Scale.FILL)
153+
// Disable hardware bitmaps, since Palette uses Bitmap.getPixels()
154+
.allowHardware(false)
155+
.build()
156+
157+
val bitmap = when (val result = Coil.execute(r)) {
158+
is SuccessResult -> result.drawable.toBitmap()
159+
else -> null
160+
}
161+
162+
return bitmap?.let {
163+
withContext(Dispatchers.Default) {
164+
val palette = Palette.Builder(bitmap)
165+
// Disable any bitmap resizing in Palette. We've already loaded an appropriately
166+
// sized bitmap through Coil
167+
.resizeBitmapArea(0)
168+
// Clear any built-in filters. We want the unfiltered dominant color
169+
.clearFilters()
170+
// We reduce the maximum color count down to 8
171+
.maximumColorCount(8)
172+
.generate()
173+
174+
palette.swatches
175+
}
176+
} ?: emptyList()
177+
}

‎Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,18 @@ fun Modifier.verticalGradientScrim(
4949
numStops: Int = 16
5050
): Modifier = composed {
5151
val colors = remember(color, numStops) {
52-
val baseAlpha = color.alpha
53-
List(numStops) { i ->
54-
val x = i * 1f / (numStops - 1)
55-
val opacity = x.pow(decay)
56-
color.copy(alpha = baseAlpha * opacity)
52+
if (decay != 1f) {
53+
// If we have a non-linear decay, we need to create the color gradient steps
54+
// manually
55+
val baseAlpha = color.alpha
56+
List(numStops) { i ->
57+
val x = i * 1f / (numStops - 1)
58+
val opacity = x.pow(decay)
59+
color.copy(alpha = baseAlpha * opacity)
60+
}
61+
} else {
62+
// If we have a linear decay, we just create a simple list of start + end colors
63+
listOf(color.copy(alpha = 0f), color)
5764
}
5865
}
5966

‎Jetcaster/app/src/main/java/com/example/jetcaster/util/Palette.kt

Lines changed: 0 additions & 165 deletions
This file was deleted.

0 commit comments

Comments
 (0)
Please sign in to comment.