In this article I will show you a barely known Jetpack library, Palette It can extract significant colors from an android.graphics.Bitmap
. We will use the data created by Palette in a Jetpack Compose app. Here's how the app looks like right after the start:
After clicking the FAB the user can select an image. Then, the app looks like this:
Not too bad, right? Let's dive into the source code. You can find it on GitHub.
Loading a bitmap and getting a palette
To use Jetpack Palette, we need to add it to the implementation dependencies.
implementation 'androidx.palette:palette-ktx:1.0.0'
Next, let's look at the activity.
class PaletteDemoActivity : ComponentActivity() {
private lateinit var viewModel: PaletteDemoViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this).get(PaletteDemoViewModel::class.java)
setContent {
PaletteDemoTheme {
Surface(color = MaterialTheme.colors.background) {
PaletteDemo(
onClick = { showGallery() }
)
}
}
}
}
…
To get a nice architecture, we use ViewModel
. You will see shortly where viewModel
is used in the activity. But let's look at PaletteViewModel
first.
class PaletteDemoViewModel : ViewModel() {
private val _bitmap: MutableLiveData<Bitmap> =
MutableLiveData<Bitmap>()
val bitmap: LiveData<Bitmap>
get() = _bitmap
fun setBitmap(bitmap: Bitmap) {
_bitmap.value = bitmap
}
private val _palette: MutableLiveData<Palette> =
MutableLiveData<Palette>()
val palette: LiveData<Palette>
get() = _palette
fun setPalette(palette: Palette) {
_palette.value = palette
}
}
So, we have two properties, a bitmap and a palette. Both will be set from inside the activity and consumed inside composable functions. PaletteDemo()
is the root of my composable hierarchy. It receives a lambda expression that invokes showGallery()
. Here's what this function does:
private fun showGallery() {
val intent = Intent(Intent.ACTION_PICK)
intent.type = "image/*"
val mimeTypes =
arrayOf("image/jpeg", "image/png")
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
startActivityForResult(intent, REQUEST_GALLERY)
}
Please note that we should replace startActivityForResult()
because it is deprecated in ComponentActivity
, but let's save this for a future article. 😎 Next comes the interesting part. What happens when the user has picked an image?
override fun onActivityResult(requestCode: Int,
resultCode: Int,
intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
when (requestCode) {
REQUEST_GALLERY -> {
if (resultCode == RESULT_OK) {
intent?.let {
it.data?.let { uri ->
val source = ImageDecoder.createSource(
contentResolver,
uri
)
val bitmap = ImageDecoder.decodeBitmap(source).asShared()
viewModel.setBitmap(bitmap)
lifecycleScope.launch {
viewModel.setPalette(
Palette.Builder(bitmap).generate()
)
}
}
}
}
}
}
}
To get a bitmap, we first create a source using ImageDecoder.createSource()
. The source is then passed to ImageDecoder.decodeBitmap()
. Have you spotted asShared()
? Inside Jetpack Palette, getPixels()
is invoked. This method may fail with IllegalStateException: unable to getPixels(), pixel access is not supported on Config#HARDWARE bitmaps
. asShared()
prevents this. The docs say:
Return an immutable bitmap backed by shared memory which
can be efficiently passed between processes via Parcelable.If this bitmap already meets these criteria it will return
itself.
The method was introduced with API level 31, so to support older platforms you should replace it by something similar.
Back to the code. How do we obtain significant colors from the bitmap? First we create a Palette.Builder
instance, passing the bitmap. Then we invoke generate()
on this object. Besides this synchronous version, there is also a variant based on AsyncTask
. As you can see, I chose to use a coroutine instead.
Now, let's turn to the composable function PaletteDemo()
.
Composing the palette
Most Compose apps will have a Scaffold()
as its root, which may include a TopAppBar()
and, like my example, a FloatingActionButton()
. My content area consists of a vertically scrollable Column()
, which remains empty until an image has been selected. Then it contains an Image()
and several Box()
elements which represent the significant colors.
@Composable
fun PaletteDemo(
viewModel: PaletteDemoViewModel = viewModel(),
onClick: () -> Unit
) {
val bitmap = viewModel.bitmap.observeAsState()
val palette = viewModel.palette.observeAsState()
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(id =
R.string.app_name)) })
},
floatingActionButton = {
FloatingActionButton(onClick = onClick) {
Icon(
Icons.Default.Search,
contentDescription =
stringResource(id = R.string.select)
)
}
}
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
bitmap.value?.run {
Image(
bitmap = asImageBitmap(),
contentDescription = null,
alignment = Alignment.Center
)
}
palette.value?.run {
swatches.forEach {
Box(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
.height(32.dp)
.clip(RectangleShape)
.background(Color(it.rgb))
)
}
}
}
}
}
To get informed about changes, we need to invoke observeAsState()
on both bitmap
and palette
. Have you noticed asImageBitmap()
? It converts the bitmap to ImageBitmap
.
There a quite a few methods you can invoke on Palette
instances, for example getVibrantColor()
or getDarkVibrantColor()
. My code just iterates over a list of swatches. Please refer to the documentation for details.
Conclusion
Using Jetpack Palette inside Composable apps is easy and fun. It will be interesting to see if the library receives updates in the wake of Material You. I hope you liked this post. Please share your thoughts in the comments.
Top comments (1)
Good article!
I was wondering about
lifecycleScope.launch
, AFAIK it launches on the main thread right? Any particular reason?