MaterialPreferences

Additional

Language
Kotlin
Version
2.0 (Oct 16, 2023)
Created
Mar 16, 2021
Updated
Oct 16, 2023
Owner
MFlisar
Contributors
MFlisar
Amr G Hegazi (amrg101)
2
Activity
Badge
Generate
Download
Source code

Material Preferences

This library is dependent on KotPreferences, a library that is based on Flows and Coroutines and works with a provided DataStore Storage or even with a custom storage implementation. It supports LiveData by default as Flows can easily be converted to LiveData. Preferences are elegantly declared via delegates.

This is an UI addition to KotPreferences and provides a DSL to easily set up RecyclerView based preference screens. It also supports custom extensions for custom preference screens.

Following are the key features:

  • define preferences elegantly via delegates (KotPreferences)
  • flow and coroutine based (KotPreferences)
  • allows to observe single / some / all preferences (KotPreferences)
  • provides suspending update functions (KotPreferences)
  • provides a DSL for a RecyclerView based setting screen

COMPOSE VERSION

You can find a compose version of this library under https://github.com/MFlisar/ComposePreferences

IMPORTANT INFORMATION (2023-10-16)

I splitted up the library into KotPreferences and this library. Check out the migration guide here

This split up was made because I created a new preferences library for compose which uses the same core modules!

Gradle (via JitPack.io)

  1. add jitpack to your project's build.gradle:
repositories {
    maven { url "https://jitpack.io" }
}
  1. add the compile statement to your module's build.gradle:
dependencies {

    val kotPreferences = "<LATEST-VERSION>"
    val materialPreferences = "<LATEST-VERSION>"
    
    // --------------
    // KotPreferences
    // --------------
    
    // core module
    implementation("com.github.MFlisar.KotPreferences:core:$kotPreferences")
    
    // data store module
    implementation("com.github.MFlisar.KotPreferences:datastore:$kotPreferences")
    
    // encryption module
    implementation("com.github.MFlisar.KotPreferences:encryption-aes:$kotPreferences") 
    
    // -------------------
    // MaterialPreferences
    // -------------------
    
    // screen modules
    implementation("com.github.MFlisar.MaterialPreferences:screen:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-bool:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-input:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-choice:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-color:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-slider:$materialPreferences")
    implementation("com.github.MFlisar.MaterialPreferences:screen-image:$materialPreferences")
}

The latest KotPreferences release can be found here The latest MaterialPreferences release can be found here

Screenshots

Example

With this library you can declare preferences via kotlin delegates and observe and update them via kotlin Flows. This works with any storage implementation, an implementation for JetPack DataStore is provided already.

Documentation

For the documentation of how preferences work and can be used, please check out the documentation of KotPreferences here

DEMO APP

Check the demo app for more informations.

This modules are placed inside the screen-* artifacts.

Usage with build in settings activity (PREFFERED)

This is an activity with a toolbar and a back button and can be shown as following:

fun showDefaultSettingsActivity(activity: AppCompatActivity) {
    SettingsActivity.start(activity, ScreenCreator)
}

@Parcelize
object ScreenCreator : SettingsActivity.IScreenCreator {
    override fun createScreen(activity: AppCompatActivity, savedInstanceState: Bundle?, updateTitle: (title: String) -> Unit): PreferenceScreen {
        return screen {
             // ... set up your preference screen here
        }
    }
}

This uses a "trick" to provide a small and efficient parcelable setup via an object to avoid any problems (either memory nor speed wise) with the parcel size limit and still provides a convenient and simple way to use this library without having to write your own settings activity.

Usage with a custom activity (ALTERNATIVE)

Alternatively you can simple extend BaseSettingsActivity and implement the single abstract createScreen function there s shown inside the CustomSettingsActivity and with this method you can of course also embed the settings screen inside any bigger layout.

Generally the manual approach works as simple as following:

  • create the screen
  • bind it to a RecyclerView
  • forward the back press event to the screen so that it can handle its internal backstack

Here's an example:

class CustomSettingsActivity : AppCompatActivity() {
companion object {
fun start(context: Context) {
val intent = Intent(context, CustomSettingsActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
lateinit var binding: PreferenceActivitySettingsBinding
lateinit var preferenceScreen: PreferenceScreen
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppCompatDelegate.setDefaultNightMode(if (DemoSettingsModel.darkTheme.value) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO)
binding = PreferenceActivitySettingsBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// ---------------
// set up settings
// ---------------
preferenceScreen = initSettings(savedInstanceState)
}
private fun initSettings(savedInstanceState: Bundle?): PreferenceScreen {
// global settings to avoid
// INFO:
// some global settings can be overwritten per preference (e.g. bottomSheet yes/no)
// other global settings can only be changed globally
// following is optional!
PreferenceScreenConfig.apply {
bottomSheet = false // default: false
maxLinesTitle = 1 // default: 1
maxLinesSummary = 3 // default: 3
noIconVisibility = NoIconVisibility.Invisible // default: Invisible
alignIconsWithBackArrow = false // default: false
}
// -----------------
// 1) create screen(s)
// -----------------
val screen = screen {
// set up screen
state = savedInstanceState
onScreenChanged = { subScreenStack, stateRestored ->
val breadcrumbs =
subScreenStack.joinToString(" > ") { it.title.get(this@CustomSettingsActivity) }
L.d { "Preference Screen - level = ${subScreenStack.size} | $breadcrumbs | restored: $stateRestored" }
supportActionBar?.subtitle = breadcrumbs
}
// -----------------
// create settings screen
// -----------------
DemoSettings.createSettingsScreen(this, this@CustomSettingsActivity)
}
screen.bind(binding.rvSettings, this)
return screen
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
preferenceScreen.onSaveInstanceState(outState)
}
override fun onBackPressed() {
if (preferenceScreen.onBackPressed()) {
return
}
super.onBackPressed()
}
override fun onSupportNavigateUp(): Boolean {
if (!preferenceScreen.onBackPressed()) {
finish()
}
return true

Example - Screen

Here's an example:

val screen = screen {
  state = savedInstanceState
  category {
    title = "Test App Style".asText()
  }
  input(UserSettingsModel.name) {
    title = "Name".asText()
  }
  switch(UserSettingsModel.alive) {
    title = "Alive".asText()
  }
  subScreen {
    title = "More".asText()
    color(UserSettingsModel.hairColor) {
      title = "Hair Color".asText()
    }
    input(UserSettingsModel.age) {
      title = "Age".asText()
    }
  }
}

Check out the demo app code for more details and especially the screen definitions in the demo here:

fun createSettingsScreen(builder: Screen.Builder, activity: AppCompatActivity) {
builder.apply {
// -----------------
// 1) test app settings (root level)
// -----------------
category {
title = "Test App Style".asText()
}
switch(DemoSettingsModel.darkTheme) {
title = "Dark Theme".asText()
icon = R.drawable.ic_baseline_style_24.asIcon()
summary = "This setting is applied to this demo app\n(enabled: %b)".asText()
onChanged = {
L.d { "Dark Theme Settings Listener called: $it" }
//recreate()
AppCompatDelegate.setDefaultNightMode(if (it) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO)
}
}
category {
title = "Demos".asText()
}
// -----------------
// 2) sub screens
// -----------------
subScreen {
title = "Sub Screen Nesting".asText()
summary = "Test nested screens (with any nesting hierarchy)".asText()
icon = R.drawable.ic_baseline_double_arrow_24.asIcon()
category {
title = "Sub Screens".asText()
}
subScreen {
title = "Sub Screen 1".asText()
icon = R.drawable.ic_baseline_double_arrow_24.asIcon()
category {
title = this@subScreen.title
}
button {
title = "Button 1.1".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
button {
title = "Button 1.2".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
subScreen {
title = "Sub Sub Screen 1".asText()
icon = R.drawable.ic_baseline_double_arrow_24.asIcon()
category {
title = this@subScreen.title
}
button {
title = "Button 1.3.1".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
button {
title = "Button 1.3.2".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
}
}
subScreen {
title = "Sub Screen 2".asText()
icon = R.drawable.ic_baseline_double_arrow_24.asIcon()
category {
title = this@subScreen.title
}
button {
title = "Button 2.1".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
button {
title = "Button 2.2".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
subScreen {
title = "Sub Sub Screen 2".asText()
icon = R.drawable.ic_baseline_double_arrow_24.asIcon()
category {
title = this@subScreen.title
}
button {
title = "Button 2.3.1".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
button {
title = "Button 2.3.2".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
}
}
}
}
// -----------------
// 3) sub screen booleans
// -----------------
subScreen {
title = "Booleans".asText()
icon = R.drawable.ic_baseline_check_box_24.asIcon()
summary = "Switches / Checkboxes".asText()
category {
title = "Switches".asText()
}
switch(DemoSettingsModel.enableFeature1) {
title = "Feature 1".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
}
switch(DemoSettingsModel.enableFeature2) {
title = "Feature 2".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
}
category {
title = "Checkboxes".asText()
}
checkbox(DemoSettingsModel.enableFeature3) {
title = "Feature 3".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
}
checkbox(DemoSettingsModel.enableFeature4) {
title = "Feature 4".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
}
}
// -----------------
// 4) sub screen inputs (text + numbers)
// -----------------
subScreen {
title = "Inputs".asText()
icon = R.drawable.ic_baseline_text_fields_24.asIcon()
summary = "Works with int/long/float/double and string preferences!".asText()
category {
title = "Inputs".asText()
}
input(DemoSettingsModel.text1) {
title = "Input 1".asText()
icon = R.drawable.ic_baseline_text_fields_24.asIcon()
summary = "Insert ANY text\n(value = %s)".asText()
hint = "Insert a value...".asText()
}
input(DemoSettingsModel.text2) {
title = "Input 2".asText()
icon = R.drawable.ic_baseline_text_fields_24.asIcon()
summary = "Insert NUMBERS only\n(value = %s)".asText()
textInputType = InputType.TYPE_CLASS_NUMBER
hint = "Insert a value...".asText()
}
input(DemoSettingsModel.number1) {
title = "Euros (Int)".asText()
icon = R.drawable.ic_baseline_attach_money_24.asIcon()
summary = "%d€".asText()
hint = "Insert an amount in $".asText()
}
input(DemoSettingsModel.number2) {
title = "Dollars (Int)".asText()
icon = R.drawable.ic_baseline_attach_money_24.asIcon()
summary = "%d$".asText()
hint = "Insert an amount in €".asText()
}
category {
title = "Number Types".asText()
}
input(DemoSettingsModel.numberFloat) {
title = "Float".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
summary = "%s".asText()
hint = "Float".asText()
}
input(DemoSettingsModel.numberDouble) {
title = "Double".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
summary = "%s".asText()
hint = "Double".asText()
}
input(DemoSettingsModel.numberLong) {
title = "Long".asText()
icon = R.drawable.ic_baseline_keyboard_arrow_right_24.asIcon()
summary = "%d".asText()
hint = "Long".asText()
}
}
// -----------------
// 5) sub screen color
// -----------------
subScreen {
title = "Colors".asText()
icon = R.drawable.ic_baseline_color_lens_24.asIcon()
summary = "Alpha support is optional".asText()
category {
title = "Colors".asText()
}
color(DemoSettingsModel.color1) {
title = "Color 1".asText()
icon = R.drawable.ic_baseline_color_lens_24.asIcon()
summary = "This color SUPPORTS alpha values".asText()
}
color(DemoSettingsModel.color2) {
title = "Color 2".asText()
icon = R.drawable.ic_baseline_color_lens_24.asIcon()
summary = "This color DOES NOT SUPPORT alpha values".asText()
supportsAlpha = false
}
color(DemoSettingsModel.color3) {
title = "Color 3".asText()
icon = R.drawable.ic_baseline_color_lens_24.asIcon()
summary = "This color also has an alpha value by default".asText()
}
}
// -----------------
// 6) sub screen buttons
// -----------------
var buttonIconIndex = 0
subScreen {
title = "Buttons".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
summary = "Show messages / dialogs / ...".asText()
category {
title = "Buttons".asText()
}
button {
title = "Button 1".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
onClick = {
activity.showMessage("Button 1 clicked!")
false
}
}
button {
title = "Button 2".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
onClick = {
activity.showMessage("Button 2 clicked!")
false
}
}
button {
title = "Button 3".asText()
summary = "Button with a value".asText()
icon = arrayOf(
R.drawable.ic_baseline_touch_app_24.asIcon(),
R.drawable.ic_baseline_attach_money_24.asIcon(),
R.drawable.ic_baseline_check_box_24.asIcon(),
)[buttonIconIndex % 3]
onClick = {
buttonIconIndex++
icon = arrayOf(
R.drawable.ic_baseline_touch_app_24.asIcon(),
R.drawable.ic_baseline_attach_money_24.asIcon(),
R.drawable.ic_baseline_check_box_24.asIcon(),
)[buttonIconIndex % 3]
summary = "Button with a value (clicked: $buttonIconIndex)".asText()
// this items needs to be updated, as we have just changed its icon
this@apply.notifyItemChanged(this)
}
}
}
// -----------------
// 7) sub screen dependencies
// -----------------
subScreen {
title = "Dependencies".asText()
icon = R.drawable.ic_baseline_supervisor_account_24.asIcon()
summary = "Enable/Show settings based on another setting".asText()
category {
title = "Dependencies - Enabled/Disabled".asText()
}
switch(DemoSettingsModel.enableChild) {
title = "Enable children".asText()
icon = R.drawable.ic_baseline_supervisor_account_24.asIcon()
summary = "Enables children below".asText()
onChanged = {
activity.showMessage("Enable children changed: $it")
}
}
listOf(
DemoSettingsModel.childName1,
DemoSettingsModel.childName2,
DemoSettingsModel.childName3
).forEachIndexed { index, setting ->
input(setting) {
title = "Child ${index + 1}".asText()
icon = R.drawable.ic_baseline_person_24.asIcon()
enabledDependsOn = DemoSettingsModel.enableChild.asDependency()
}
}
category {
title = "Dependencies - Show/Hide".asText()
}
switch(DemoSettingsModel.showChild) {
title = "Show children".asText()
icon = R.drawable.ic_baseline_supervisor_account_24.asIcon()
summary = "Show children below".asText()
onChanged = {
activity.showMessage("Show children changed: $it")
}
}
listOf(
DemoSettingsModel.showChildName1,
DemoSettingsModel.showChildName2,
DemoSettingsModel.showChildName3
).forEachIndexed { index, setting ->
input(setting) {
title = "Child ${index + 1}".asText()
icon = R.drawable.ic_baseline_person_24.asIcon()
visibilityDependsOn = DemoSettingsModel.showChild.asDependency()
}
}
category {
title = "Custom Dependency".asText()
}
input(DemoSettingsModel.parentOfCustomDependency) {
title = "Parent".asText()
icon = R.drawable.ic_baseline_supervisor_account_24.asIcon()
summary =
"Must contain a string that is a valid number between [0, 100] to enable the next setting".asText()
}
button {
title = "Button with custom enable dependency on the setting above".asText()
icon = R.drawable.ic_baseline_person_24.asIcon()
onClick = {
activity.showMessage("Button clicked - parent must contain a string representing a number between [0, 100] now")
false
}
enabledDependsOn = object : Dependency<String> {
override val setting = DemoSettingsModel.parentOfCustomDependency
override suspend fun state(): Boolean {
val value = setting.flow.first().toIntOrNull()
return value != null && value >= 0 && value <= 100
}
}
}
button {
title = "Button with custom visibility dependency on the setting above".asText()
icon = R.drawable.ic_baseline_person_24.asIcon()
onClick = {
activity.showMessage("Button clicked - parent must contain a string representing a number between [0, 100] now")
false
}
visibilityDependsOn = object : Dependency<String> {
override val setting = DemoSettingsModel.parentOfCustomDependency
override suspend fun state(): Boolean {
val value = setting.flow.first().toIntOrNull()
return value != null && value >= 0 && value <= 100
}
}
}
}
// -----------------
// 8) sub screen choices
// -----------------
val demoChoices = listOf(
"Value 1",
"Value 2",
"Value 3",
"Value 4",
"Value 5"
).asChoiceListString()
subScreen {
title = "Choices".asText()
summary = "Single / Multi Choices".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
category {
title = "Choices".asText()
}
singleChoice(DemoSettingsModel.choiceSingle, demoChoices) {
title = "Single Choice".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
//showCheckBoxes = true
}
multiChoice(DemoSettingsModel.choiceMulti, demoChoices) {
title = "Multi Choice".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
allowEmptySelection = true
}
singleChoice(
DemoSettingsModel.testEnum,
TestEnum.values(),
{ "Enum: ${it.name}" }) {
title = "Single Choice Enum".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
}
singleChoice(DemoSettingsModel.choiceSingle3, demoChoices) {
title = "Single Choice + Close on click".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
closeOnSelect = true
}
}
// -----------------
// 9) various
// -----------------
subScreen {
title = "Various".asText()
icon = R.drawable.ic_baseline_format_list_bulleted_24.asIcon()
button {
title = "Button".asText()
summary = "Custom Badge Colors".asText()
icon = R.drawable.ic_baseline_touch_app_24.asIcon()
badge = Badge.Text("Test".asText(), Color.GREEN)
}
button {
title = "Button".asText()
summary = "No Icon Visibility INVISIBLE".asText()
noIconVisibility = NoIconVisibility.Invisible
}
button {
title = "Button".asText()
summary = "No Icon Visibility GONE".asText()
noIconVisibility = NoIconVisibility.Gone
}
}
// -----------------
// .) preferences inside root screen
// -----------------
category {
title = "Root Level Preferences".asText()
}
switch(DemoSettingsModel.proFeature1) {
title = "Pro Feature 1".asText()
icon = R.drawable.ic_baseline_phone_android_24.asIcon()
summary = "Enable a fancy pro feature".asText()
badge = "PRO".asBatch()
canChange = {
// we can't change this settings, it's enabled but will only work in the pro version
activity.showMessage(
"Changing this setting is disabled via 'canChange' function!",
Toast.LENGTH_LONG
)
false
}
onChanged = {
activity.showMessage("Pro feature changed (this should never be called!): $it")
}
}
switch(DemoSettingsModel.proFeature2) {
title = "Pro Feature 2".asText()
icon = R.drawable.ic_baseline_phone_android_24.asIcon()
summary = "This setting is always disabled".asText()
badge = "PRO".asBatch()
enabled = false
}
}
}
}

Supported Settings

Following settings are supported already:

  • Category
  • Sub Screens (supports nesting)
  • Checkbox
  • Switch
  • Input (text, number, password, ...)
  • Buttons
  • Color (w/o alpha)
  • Slider (Seekbar)

And following features are supported:

  • callback to check if a value is allowed to be changed (e.g. to only allow a change in the pro version)
  • dependency on other preference (even with custom dependency evaluator)
  • badges to display a badge next to a settings title
  • restores list state automatically even in nested preferences

Default Settings

Some values can be defined globally and will be used by all preferences - those default values are stored in the PreferenceScreenConfig object. You can change dafault values like following:

PreferenceScreenConfig.apply {
    bottomSheet = true  // default: false
    maxLinesTitle = 1   // default: 1
    maxLinesSummary = 3 // default: 3
}

Credits

Special thanks goes to ModernAndroidPreferences because I copied a few things from there, namely following:

  • the root layout xml
  • the RecyclerView animations
  • the badge idea and the badge drawable
  • the basic idea for the DSL