因此,设置页面的 xml 格式似乎不太可能,因此我决定在 Jetpack Compose 中实现我的设置活动。我的解决方案绝不是最优雅的(并且需要更多的 cmets,我知道),但是如何以“正确”的方式做到这一点绝对超出了我的知识范围。
可组合首选项.kt:
// TODO your package here
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.ContentAlpha
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.preference.PreferenceManager
abstract class ComposablePreference<T>(val key: String) {
abstract var value: T
val onPreferenceChangeListener: MutableList<ComposablePreferenceChangeListener<T>> = ArrayList()
fun shouldPersistChange(newValue: T): Boolean {
for (preferenceChangeListener in onPreferenceChangeListener) {
if (!preferenceChangeListener.onPreferenceChange(this, newValue)) return false
}
return true
}
abstract fun getValue(default: T): T
}
class EphemeralPreference<T>(key: String, override var value: T) : ComposablePreference<T>(key) {
override fun getValue(default: T): T {
return value
}
}
class BooleanPreference(key: String, val context: Context) : ComposablePreference<Boolean>(key) {
override var value
get() = getValue(false)
set(value) {
if (!shouldPersistChange(value)) return
PreferenceManager.getDefaultSharedPreferences(context).edit().apply {
putBoolean(key, value)
apply()
}
}
override fun getValue(default: Boolean): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, default)
}
}
class StringPreference(key: String, val context: Context) : ComposablePreference<String>(key) {
override var value: String
get() = getValue("")
set(value) {
if (!shouldPersistChange(value)) return
PreferenceManager.getDefaultSharedPreferences(context).edit().apply {
putString(key, value)
apply()
}
}
override fun getValue(default: String): String {
return PreferenceManager.getDefaultSharedPreferences(context).getString(key, default)
?: default
}
}
class StringSetPreference(key: String, val context: Context) :
ComposablePreference<Set<String>>(key) {
override var value: Set<String>
get() = getValue(HashSet())
set(value) {
if (!shouldPersistChange(value)) return
PreferenceManager.getDefaultSharedPreferences(context).edit().apply {
putStringSet(key, value)
apply()
}
}
override fun getValue(default: Set<String>): Set<String> {
return PreferenceManager.getDefaultSharedPreferences(context).getStringSet(key, default)
?: default
}
}
class FloatPreference(key: String, val context: Context) : ComposablePreference<Float>(key) {
override var value: Float
get() = getValue(0f)
set(value) {
if (!shouldPersistChange(value)) return
PreferenceManager.getDefaultSharedPreferences(context).edit().apply {
putFloat(key, value)
apply()
}
}
override fun getValue(default: Float): Float {
return PreferenceManager.getDefaultSharedPreferences(context).getFloat(key, default)
}
}
interface ComposablePreferenceChangeListener<T> {
fun onPreferenceChange(preference: ComposablePreference<T>, newValue: T): Boolean
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StringPreferenceChange(
preference: ComposablePreference<String>,
dismissDialog: () -> Unit,
context: Context,
title: String,
validate: ((String) -> String)? = null
) {
var newValue by remember { mutableStateOf(preference.value) }
var message by remember { mutableStateOf( "" )}
AlertDialog(onDismissRequest = { dismissDialog() }, dismissButton = {
OutlinedButton(onClick = dismissDialog) {
Text(text = context.getString(android.R.string.cancel))
}
}, confirmButton = {
Button(onClick = {
message = validate?.invoke(newValue) ?: ""
if (message.isBlank()) {
return@Button
}
preference.value = newValue
dismissDialog()
}) {
Text(text = context.getString(android.R.string.ok))
}
}, title = {
Text(text = title)
}, text = {
Column {
OutlinedTextField(value = newValue, onValueChange = { newValue = it })
Text(
text = message,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium)
)
}
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FloatPreferenceChange(
preference: ComposablePreference<Float>,
dismissDialog: () -> Unit,
context: Context,
title: String,
validate: ((Float) -> String)? = null
) {
var newValue by remember { mutableStateOf(preference.value.toString()) }
var message by remember {
mutableStateOf("")
}
AlertDialog(onDismissRequest = { dismissDialog() }, dismissButton = {
OutlinedButton(onClick = dismissDialog) {
Text(text = context.getString(android.R.string.cancel))
}
}, confirmButton = {
Button(onClick = {
try {
val temp = newValue.toFloat()
message = validate?.invoke(temp) ?: ""
if (message.isNotBlank()) {
return@Button
}
preference.value = newValue.toFloat()
dismissDialog()
} catch (e: NumberFormatException) {
message = context.getString(R.string.invalid_float)
}
}) {
Text(text = context.getString(android.R.string.ok))
}
}, title = {
Text(text = title)
}, text = {
Column {
OutlinedTextField(value = newValue, onValueChange = { newValue = it }, isError =
message.isNotBlank())
Text(
text = message,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium)
)
}
})
}
@Composable
fun MultiSelectListPreferenceChange(
preference: ComposablePreference<Set<String>>,
dismissDialog: () -> Unit,
context: Context,
title: String,
entriesRes: Int,
entryValuesRes: Int
) {
val newValue = remember {
mutableStateMapOf(*preference.value.map {
Pair(
it, true
)
}.toTypedArray())
}
val entries = context.resources.getStringArray(entriesRes)
val entryValues = context.resources.getStringArray(entryValuesRes)
AlertDialog(onDismissRequest = { dismissDialog() }, dismissButton = {
OutlinedButton(onClick = dismissDialog) {
Text(text = context.getString(android.R.string.cancel))
}
}, confirmButton = {
Button(onClick = {
preference.value = newValue.keys
dismissDialog()
}) {
Text(text = context.getString(android.R.string.ok))
}
}, title = {
Text(text = title)
}, text = {
Column {
for ((index, entry) in entries.withIndex()) {
Row(
modifier = Modifier.toggleable(value = newValue.containsKey(entry),
onValueChange = {
if (it) {
newValue[entry] = true
} else {
newValue.remove(entry)
}
})
) {
Checkbox(
checked = newValue.containsKey(entry), onCheckedChange = null/*{
if (it) {
newValue[entry] = true
} else {
newValue.remove(entry)
}
}*/
)
Text(text = entryValues[index])
}
}
}
})
}
fun multiselectListPreferenceSummary(
value: Set<String>, entries: Array<String>, values: Array<String>
): String {
val summaryList: MutableList<String> = ArrayList(value.size)
for (i in values.indices) {
if (value.contains(values[i])) summaryList.add(entries[i])
}
return summaryList
.joinToString(", ")
}
@Composable
fun CheckboxAction(value: Boolean) {
Switch(checked = value, onCheckedChange = {})
}
设置活动.kt:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreferenceScreen(
preferences: List<Pair<Pair<Int, (@Composable () -> Unit)?>, @Composable () -> Unit>>,
selected: Int,
currentPreferenceGroup: @Composable () -> Unit,
screenClass: WindowWidthSizeClass,
onGroupSelected: (key: Int, preferences: @Composable () -> Unit) -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
) {
// Remember a SystemUiController
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
val scrim = MaterialTheme.colorScheme.scrim
if (selected == 0) {
onGroupSelected(preferences.firstOrNull()?.first?.first ?: 0, preferences.firstOrNull()
?.second ?: {})
}
val phone = screenClass == WindowWidthSizeClass.Compact
DisposableEffect(systemUiController, useDarkIcons) {
// Update all of the system bar colors to be transparent, and use
// dark icons if we're in light theme
systemUiController.setNavigationBarColor(
color = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Color.Transparent
else scrim,
darkIcons = useDarkIcons
)
// setStatusBarColor() and setNavigationBarColor() also exist
onDispose {}
}
val phoneScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val tabletScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(topBar = {
if (phone) {
LargeTopAppBar(colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
scrolledContainerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
), title = {
Text(
getString(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}, scrollBehavior = phoneScrollBehavior, navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher.onBackPressed()
}) {
Icon(
Icons.Default.ArrowBack, getString(
R.string.back
)
)
}
})
} else {
TopAppBar(colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
scrolledContainerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
), title = {
Text(
getString(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}, scrollBehavior = tabletScrollBehavior, navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher.onBackPressed()
}) {
Icon(
Icons.Default.ArrowBack, getString(
R.string.back
)
)
}
})
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.nestedScroll(if (phone) phoneScrollBehavior
.nestedScrollConnection else tabletScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background
) {
if (screenClass == WindowWidthSizeClass.Compact) {
LazyColumn(
modifier = Modifier
.selectableGroup()
.waterfallPadding(), contentPadding = it
) {
items(
items = preferences,
key = { preference -> preference.first.first }) { preference ->
PreferenceGroup(
title = preference.first.first,
icon = preference.first.second,
onClick = {
onGroupSelected(preference.first.first, preference.second)
},
selected = preference.first.first == selected,
tablet = screenClass != WindowWidthSizeClass.Compact,
preferences = preference.second,
first = preference.first.first == preferences.firstOrNull()?.first?.first
)
}
}
} else {
Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Top) {
LazyColumn(
modifier = Modifier
.selectableGroup()
.waterfallPadding()
.fillMaxWidth(0.35f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentPadding = it,
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = preferences,
key = { preference -> preference.first.first }) { preference ->
PreferenceGroup(
title = preference.first.first,
icon = preference.first.second,
onClick = {
onGroupSelected(preference.first.first, preference.second)
},
selected = preference.first.first == selected,
tablet = screenClass != WindowWidthSizeClass.Compact,
preferences = preference.second,
first = preference.first.first == preferences.firstOrNull()?.first?.first
)
}
}
Column(modifier = Modifier.weight(1f, true)) {
Spacer(modifier = Modifier.height(it.calculateTopPadding()))
currentPreferenceGroup()
}
}
}
}
}
@Composable
fun PreferenceGroup(
title: Int,
selected: Boolean,
tablet: Boolean,
onClick: () -> Unit,
preferences: @Composable () -> Unit,
first: Boolean = false,
icon: (@Composable () -> Unit)? = null
) {
assert(!selected || tablet)
if (tablet) {
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(top = 10.dp, bottom = 10.dp)
.height(100.dp)
.clip(
RoundedCornerShape(12.dp)
)
.background(
if (selected) MaterialTheme.colorScheme.inversePrimary else Color.Transparent
)
.selectable(selected = selected, onClick = onClick)
.padding(start = 8.dp, end = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Layout(content = {
icon?.invoke()
Text(
text = getString(title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow = TextOverflow.Ellipsis,
)
}, modifier = Modifier.fillMaxSize(), measurePolicy = { measurables,
constraints ->
/*
We want to position the text in the center of the box
If there is an icon, we want the icon to be left-aligned
The icon may require the text to be cut off and/or move to the right
[ (ic) text ]
[ text w/out icon ]
[ (ic) long text... ]
*/
if (measurables.isEmpty()) return@Layout layout(0, 0) {
}
val paddingSize = ICON_PADDING.toPx().toInt()
lateinit var text: Placeable
var iconPlaceable: Placeable? = null
val totalWidth: Int
val totalHeight: Int
if (measurables.size == 1) {
text = measurables[0].measure(constraints)
totalWidth = if (constraints.hasBoundedWidth) constraints.maxWidth
else text.width
totalHeight = if (constraints.hasBoundedHeight) constraints.maxHeight
else text.height
} else {
iconPlaceable = measurables[0].measure(Constraints.fixed(ICON_SIZE.toPx()
.toInt(),
ICON_SIZE.toPx().toInt()))
text = measurables[1].measure(Constraints(
0,
constraints.maxWidth - iconPlaceable.width - paddingSize,
0,
constraints.maxHeight))
totalWidth = if (constraints.hasBoundedWidth) constraints.maxWidth
else iconPlaceable.width + paddingSize + text.width
totalHeight = if (constraints.hasBoundedHeight) constraints.maxHeight
else max(iconPlaceable.height, text.height)
}
layout(totalWidth, totalHeight) {
val textX = max((totalWidth - text.width) / 2,
(iconPlaceable?.width ?: -paddingSize) + paddingSize)
val textY = (totalHeight - text.height) / 2
text.place(x = textX, y = textY)
iconPlaceable?.place(
x = 0,
y = (totalHeight - iconPlaceable.height) / 2)
}
})
}
} else {
if (!first) Divider(modifier = Modifier.fillMaxWidth(), thickness = 1.dp)
Text(
text = getString(title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp, top = LIST_ELEMENT_PADDING, bottom
= LIST_ELEMENT_PADDING),
overflow = TextOverflow.Ellipsis
)
preferences()
}
}
@Composable
fun SplitPreference(
largePreference: @Composable () -> Unit,
smallPreference: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(PREFERENCE_HEIGHT),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = modifier
.padding(end = SPLIT_PREFERENCE_PADDING)
.fillMaxHeight()
.weight(1f, fill = true),
contentAlignment = Alignment.CenterStart
) {
largePreference()
}
Divider(
modifier = Modifier
.fillMaxHeight(0.6f)
.width(1.dp),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlpha.medium)
)
Box(
modifier = modifier
.padding(start = SPLIT_PREFERENCE_PADDING)
.size(PREFERENCE_HEIGHT),
contentAlignment = Alignment.Center
) {
smallPreference()
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> DialoguePreference(
preference: ComposablePreference<T>,
title: Int,
modifier: Modifier = Modifier,
action: (@Composable (value: T) -> Unit)? = null,
summary: ((value: T) -> String)? = { value ->
value.toString()
},
icon: (@Composable BoxScope
.(enabled: Boolean) -> Unit)? = null,
reserveIconSpace: Boolean = true,
titleColor: Color = MaterialTheme.colorScheme.onSurface,
disabledTitleColor: Color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium
),
titleStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyLarge,
summaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium
),
summaryStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.labelLarge,
onPreferenceChanged: ComposablePreferenceChangeListener<T> = object :
ComposablePreferenceChangeListener<T> {
override fun onPreferenceChange(
preference: ComposablePreference<T>, newValue: T
): Boolean {
return true
}
},
enabled: Boolean = true,
default: T? = null,
dialog: @Composable (
preference: ComposablePreference<T>, dismissDialog: () -> Unit, context: Context, title: String
) -> Unit
) {
val editing = rememberSaveable {
mutableStateOf(false)
}
AnimatedContent(targetState = editing, transitionSpec = {
slideIntoContainer(
towards = AnimatedContentScope.SlideDirection.Up
) with slideOutOfContainer(
towards = AnimatedContentScope.SlideDirection.Down
)
}) { targetState ->
if (targetState.value) {
dialog(preference = preference, dismissDialog = {
editing.value = false
}, context = this@SettingsActivity, title = getString(title))
}
}
Preference(
preference = preference,
title = title,
action = action,
summary = summary,
modifier = modifier,
icon = icon,
reserveIconSpace = reserveIconSpace,
titleColor = titleColor,
disabledTitleColor = disabledTitleColor,
titleStyle = titleStyle,
summaryColor = summaryColor,
summaryStyle = summaryStyle,
onPreferenceChanged = onPreferenceChanged,
enabled = enabled,
onPreferenceClicked = {
editing.value = true
true
},
default = default
)
}
@Composable
fun <T> Preference(
preference: ComposablePreference<T>,
title: Int,
modifier: Modifier = Modifier,
action: (@Composable (value: T) -> Unit)? = null,
summary: ((value: T) -> String)? = { value ->
value.toString()
},
icon: (@Composable BoxScope
.(enabled: Boolean) -> Unit)? = null,
reserveIconSpace: Boolean = true,
titleColor: Color = MaterialTheme.colorScheme.onSurface,
disabledTitleColor: Color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium
),
titleStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyLarge,
summaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(
alpha = ContentAlpha.medium
),
summaryStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.labelLarge,
onPreferenceClicked: (preference: ComposablePreference<T>) -> Boolean = {
false
},
onPreferenceChanged: ComposablePreferenceChangeListener<T> = object :
ComposablePreferenceChangeListener<T> {
override fun onPreferenceChange(
preference: ComposablePreference<T>, newValue: T
): Boolean {
return true
}
},
enabled: Boolean = true,
default: T? = null
) {
preference.onPreferenceChangeListener.add(onPreferenceChanged)
val value = if (default != null) preference.getValue(default) else preference.value
Row(
modifier = modifier
.fillMaxWidth()
.height(73.dp)
.clickable(enabled = enabled, onClick = {
onPreferenceClicked(preference)
}),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null || reserveIconSpace) {
val iconSpot: @Composable BoxScope.(enabled: Boolean) -> Unit = icon ?: { }
Box(
modifier = Modifier
.padding(ICON_PADDING)
.size(ICON_SIZE)
.alpha(if (enabled) ContentAlpha.high else ContentAlpha.disabled),
contentAlignment = Alignment.Center,
content = {
iconSpot(enabled)
}
)
}
Column(modifier = modifier
.fillMaxHeight()
.weight(1f, fill = true),
verticalArrangement = Arrangement.Center) {
Text(
text = getString(title), style = titleStyle, color = if (enabled) titleColor
else disabledTitleColor
)
if (summary != null) Text(
text = summary(preference.value), style = summaryStyle, Color = summaryColor
)
}
if (action != null) Box(
modifier = Modifier.size(PREFERENCE_HEIGHT), contentAlignment = Alignment.Center
) { action(value) }
}
}
Companion object {
val PREFERENCE_HEIGHT = 73.dp
val SPLIT_PREFERENCE_PADDING = 5.dp
val ICON_SIZE = 24.dp
val ICON_PADDING = 16.dp
const val FADE_DURATION = 500
private fun grayScaleFilter(): ColorFilter {
val grayScaleMatrix = ColorMatrix(
floatArrayOf(
0.33f, 0.33f, 0.33f, 0f, 0f,
0.33f, 0.33f, 0.33f, 0f, 0f,
0.33f, 0.33f, 0.33f, 0f, 0f,
0f, 0f, 0f, 1f, 0f
)
)
return ColorFilter.colorMatrix(grayScaleMatrix)
}
}
我的字数有限,但我很乐意尝试回答任何问题。