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
You can find a compose version of this library under https://github.com/MFlisar/ComposePreferences
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)
- add jitpack to your project's
build.gradle
:
repositories {
maven { url "https://jitpack.io" }
}
- 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
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.
For the documentation of how preferences work and can be used, please check out the documentation of KotPreferences
here
Check the demo app for more informations.
This modules are placed inside the screen-*
artifacts.
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.
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:
Lines 15 to 105 in bd25854
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 |
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:
MaterialPreferences/demo/src/main/java/com/michaelflisar/materialpreferences/demo/DemoSettings.kt
Lines 83 to 555 in dad0db4
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 | |
} | |
} | |
} | |
} |
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
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
}
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