Created
September 2, 2022 23:42
-
-
Save Nullable-TB/2ddff445f295208084bc946b2dc87480 to your computer and use it in GitHub Desktop.
Jetpack Compose GUI example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package nullable.pest_control.gui | |
import androidx.compose.animation.* | |
import androidx.compose.animation.core.LinearOutSlowInEasing | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.* | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.material.Card | |
import androidx.compose.material.RadioButton | |
import androidx.compose.material.Surface | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.IntSize | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
@Composable | |
fun TitleCard(title: String, content: @Composable () -> Unit) { | |
Card( | |
modifier = Modifier.widthIn(400.dp, 800.dp) | |
.padding(start = 10.dp, top = 10.dp, bottom = 10.dp, end = 10.dp), | |
elevation = 5.dp | |
) { | |
Column( | |
modifier = Modifier.padding( | |
start = 10.dp, | |
top = 10.dp, | |
bottom = 10.dp, | |
end = 10.dp | |
) | |
) { | |
Text(title, fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 10.dp)) | |
content() | |
} | |
} | |
} | |
/** | |
* A full radio button solution as an elevated row. [option] represents what this particular button is, with text populating | |
* via "option.toString()". When the row is clicked [currentSelectionState]'s value will be set to [option]. | |
* | |
* [dropDownContent] is an optional composable that will display as part of this row card if the radio button is selected. | |
*/ | |
@Composable | |
fun <T> RadioButtonBar( | |
currentSelectionState: MutableState<T>, | |
option: T, | |
dropDownContent: (@Composable () -> Unit)? = null | |
) { | |
val (selectedOption, onOptionSelected) = remember { currentSelectionState } | |
Box(Modifier.padding(bottom = 3.dp)) { | |
Surface(elevation = 10.dp) { | |
Column( | |
// When the column expands, give it a nice animation | |
Modifier.animateContentSize( | |
animationSpec = tween( | |
durationMillis = 300, | |
easing = LinearOutSlowInEasing | |
) | |
).fillMaxWidth() | |
) { | |
// Make the whole row clickable. We don't want the dropdown part to be clickable, though. | |
Row(Modifier.height(35.dp).fillMaxWidth().clickable { onOptionSelected(option) }) { | |
RadioButton( | |
onClick = { onOptionSelected(option) }, | |
selected = selectedOption == option, | |
modifier = Modifier.align(Alignment.CenterVertically).size(20.dp).padding(start = 15.dp) | |
) | |
Text( | |
option.toString(), | |
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 20.dp), | |
fontSize = 15.sp | |
) | |
} | |
// Render the dropdown part if this radio button is selected | |
if (dropDownContent != null && selectedOption == option) { | |
dropDownContent() | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun ResponsiveGrid(components: List<@Composable () -> Unit>) { | |
val scrollState = rememberScrollState(0) | |
Surface { | |
BoxWithConstraints(Modifier.fillMaxSize()) { | |
if (maxWidth >= 800.dp) { | |
Column(Modifier.fillMaxWidth().verticalScroll(scrollState, true).padding(end = 20.dp)) { | |
Row(Modifier.widthIn(800.dp, 1600.dp)) { | |
Column(Modifier.fillMaxWidth().weight(1f)) { | |
components.forEachIndexed { index, func -> | |
if (index % 2 == 0) { | |
func() | |
} | |
} | |
} | |
Column(Modifier.fillMaxWidth().weight(1f)) { | |
components.forEachIndexed { index, func -> | |
if (index % 2 != 0) { | |
func() | |
} | |
} | |
} | |
} | |
} | |
VerticalScrollbar( | |
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), | |
adapter = rememberScrollbarAdapter(scrollState) | |
) | |
} else { | |
Box { | |
Column(Modifier.fillMaxWidth().verticalScroll(scrollState, true).padding(end = 20.dp)) { | |
components.forEach { it() } | |
} | |
VerticalScrollbar( | |
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), | |
adapter = rememberScrollbarAdapter(scrollState) | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun TransitionView( | |
visible: Boolean, | |
content: @Composable () -> Unit | |
) { | |
val density = LocalDensity.current | |
AnimatedVisibility( | |
visible = visible, | |
enter = slideInVertically { | |
with(density) { (-40).dp.roundToPx() } | |
} + expandVertically( | |
expandFrom = Alignment.Top | |
) + fadeIn( | |
initialAlpha = 0.3f | |
), | |
exit = slideOutVertically(targetOffsetY = { -10 }) + shrinkVertically() + fadeOut() | |
) { | |
content() | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package nullable.pest_control.gui | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.KeyboardOptions | |
import androidx.compose.foundation.window.WindowDraggableArea | |
import androidx.compose.material.* | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.ExperimentalComposeUiApi | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.input.pointer.PointerEventType | |
import androidx.compose.ui.input.pointer.onPointerEvent | |
import androidx.compose.ui.window.ApplicationScope | |
import androidx.compose.ui.window.Window | |
import androidx.compose.ui.window.application | |
import androidx.compose.ui.window.rememberWindowState | |
import androidx.compose.material.ExperimentalMaterialApi | |
import androidx.compose.material.icons.Icons | |
import androidx.compose.material.icons.filled.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.unit.* | |
import com.google.gson.GsonBuilder | |
import nullable.pest_control.* | |
fun main() { | |
// This is hardcoded but you can imagine these might be loaded from a file or something | |
val oldSettings = PestControlSettings() | |
// We don't modify the settings in place. Sometimes it's useful to be able to revert to the settings that existed | |
// before the user started messing with them. This mechanism allows that by giving a new settings object and preserving | |
// the old. | |
val guiResult = PestControlGui(oldSettings).run() | |
if (guiResult.cancelled) { | |
println("GUI cancelled") | |
} else { | |
val newSettings = guiResult.settings | |
val gson = GsonBuilder() | |
.registerTypeAdapterFactory(StateTypAdapterFactory()) | |
.setPrettyPrinting() | |
.create() | |
println("New settings") | |
println(gson.toJson(newSettings)) | |
} | |
} | |
data class GuiResults( | |
var cancelled: Boolean = true, | |
var settings: PestControlSettings | |
) | |
class PestControlGui(inputSettings: PestControlSettings) { | |
private val settings: PestControlSettings | |
private val results: GuiResults | |
init { | |
val gson = GsonBuilder().registerTypeAdapterFactory(StateTypAdapterFactory()).create() | |
// Deep copy the settings by serializing it then deserializing it into a new object | |
settings = gson.fromJson(gson.toJson(inputSettings), PestControlSettings::class.java) | |
results = GuiResults(settings = settings) | |
} | |
fun run(): GuiResults { | |
// Reset this in case this is this GUI object's second+ run | |
results.cancelled = false | |
application(exitProcessOnExit = false) { | |
AppTheme { | |
customWindow("Null Pest Control") { | |
mainGui() | |
} | |
} | |
} | |
return results | |
} | |
@Composable | |
private fun ApplicationScope.mainGui() { | |
val scaffoldState = rememberScaffoldState() | |
val navigationIndex = remember { mutableStateOf(0) } | |
Scaffold( | |
scaffoldState = scaffoldState, | |
floatingActionButton = { | |
ExtendedFloatingActionButton(text = { Text("Start Script") }, | |
icon = { Icon(Icons.Filled.PlayArrow, "") }, | |
onClick = ::exitApplication) | |
}) { | |
Row { | |
Column { | |
NavigationRail { | |
NavigationRailItem( | |
icon = { Icon(Icons.Filled.Home, "") }, | |
label = { Text("Main") }, | |
selected = navigationIndex.value == 0, | |
alwaysShowLabel = true, | |
onClick = { navigationIndex.value = 0 } | |
) | |
NavigationRailItem( | |
icon = { Icon(Icons.Filled.Settings, "") }, | |
label = { Text("Antiban") }, | |
selected = navigationIndex.value == 1, | |
alwaysShowLabel = true, | |
onClick = { navigationIndex.value = 1 } | |
) | |
} | |
} | |
// Main tab | |
TransitionView(visible = navigationIndex.value == 0) { | |
ResponsiveGrid( | |
components = listOf( | |
{ | |
TitleCard("Boat Selection") { | |
BoatSelection.values().forEach { boatSelection -> | |
RadioButtonBar(settings.boatSelection, boatSelection) | |
} | |
} | |
}, | |
{ | |
TitleCard("Play Strategy") { | |
PlayStrategy.values().forEach { strat -> | |
RadioButtonBar(settings.playStrategy, strat) | |
} | |
} | |
}, | |
{ | |
TitleCard("Special Attack") { | |
SpecAttackStrategy.values().forEach { | |
if (it == SpecAttackStrategy.Custom) { | |
RadioButtonBar(settings.specAttackStrategy, it) { | |
Row(Modifier.padding(start = 15.dp, end = 15.dp, bottom = 10.dp)) { | |
OutlinedTextField( | |
modifier = Modifier.widthIn(175.dp, 200.dp).height(50.dp), | |
value = settings.customSpecAtkWeapon.value.let { | |
if (it == 0) { | |
"" | |
} else { | |
it.toString() | |
} | |
}, | |
onValueChange = { | |
settings.customSpecAtkWeapon.value = it.toIntOrNull() ?: 0 | |
}, | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), | |
label = { Text("Weapon ID") }, | |
singleLine = true, | |
textStyle = TextStyle(fontSize = 11.sp), | |
) | |
OutlinedTextField( | |
modifier = Modifier.widthIn(160.dp, 175.dp) | |
.padding(start = 40.dp).height(50.dp), | |
value = settings.customSpecAtkWeaponPercent.value.let { | |
if (it == 0) { | |
"" | |
} else { | |
it.toString() | |
} | |
}, | |
onValueChange = { | |
settings.customSpecAtkWeaponPercent.value = | |
it.toIntOrNull() ?: 0 | |
}, | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), | |
label = { Text("% To Use") }, | |
singleLine = true, | |
textStyle = TextStyle(fontSize = 11.sp), | |
) | |
} | |
} | |
} else { | |
RadioButtonBar(settings.specAttackStrategy, it) | |
} | |
} | |
} | |
}, | |
{ | |
TitleCard("Prayer") { | |
PrayerStrategy.values().forEach { prayStrat -> | |
RadioButtonBar(settings.prayerStrategy, prayStrat) | |
} | |
} | |
}, | |
{ | |
TitleCard("World Selection") { | |
WorldStrategy.values().forEach { strat -> | |
if (strat == WorldStrategy.Custom) { | |
RadioButtonBar(settings.worldStrategy, strat) { | |
Row(Modifier.padding(start = 15.dp, end = 15.dp, bottom = 10.dp)) { | |
OutlinedTextField( | |
modifier = Modifier.widthIn(175.dp, 200.dp).height(50.dp), | |
value = settings.customWorld.value.let { | |
if (it == 0) { | |
"" | |
} else { | |
it.toString() | |
} | |
}, | |
onValueChange = { | |
settings.customWorld.value = it.toIntOrNull() ?: 0 | |
}, | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), | |
label = { Text("World") }, | |
singleLine = true, | |
textStyle = TextStyle(fontSize = 11.sp), | |
) | |
} | |
} | |
} else { | |
RadioButtonBar(settings.worldStrategy, strat) | |
} | |
} | |
} | |
} | |
) | |
) | |
} | |
// Antiban tab | |
TransitionView(visible = navigationIndex.value == 1) { | |
Text("Hello World!") | |
} | |
} | |
} | |
} | |
@OptIn(ExperimentalComposeUiApi::class) | |
@Composable | |
private fun ApplicationScope.customWindow(title: String, content: @Composable () -> Unit) { | |
val xBackgroundColor = remember { mutableStateOf(Color(0, 0, 0, 0)) } | |
Window( | |
onCloseRequest = ::exitApplication, | |
title = title, | |
state = rememberWindowState(width = 800.dp, height = 600.dp), | |
transparent = true, | |
undecorated = true, | |
) { | |
Surface( | |
modifier = Modifier.fillMaxSize() | |
.border(0.05.dp, color = Color(0xFF474747), shape = RoundedCornerShape(8.dp)), | |
shape = RoundedCornerShape(8.dp) // window has round corners now | |
) { | |
Column(Modifier.fillMaxSize()) { | |
// This is our top bar | |
Row(Modifier.fillMaxWidth().height(30.dp)) { | |
Surface(Modifier.fillMaxSize(), color = Color(0xFF111418)) { | |
WindowDraggableArea(Modifier.fillMaxSize()) { | |
Box(Modifier.fillMaxSize()) { | |
Text( | |
text = title, | |
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp) | |
) | |
IconButton( | |
modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd) | |
.background(color = xBackgroundColor.value) | |
.onPointerEvent(PointerEventType.Enter) { | |
xBackgroundColor.value = Color(0xFFE81123) | |
} | |
.onPointerEvent(PointerEventType.Exit) { | |
xBackgroundColor.value = Color(0, 0, 0, 0) | |
}, | |
onClick = { | |
results.cancelled = true | |
exitApplication() | |
} | |
) { | |
Icon(Icons.Filled.Close, "", tint = Color.Gray) | |
} | |
} | |
} | |
} | |
} | |
Row(Modifier.fillMaxSize()) { content() } | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package nullable.pest_control.gui | |
import androidx.compose.runtime.State | |
import androidx.compose.runtime.mutableStateOf | |
import com.google.gson.* | |
import com.google.gson.reflect.TypeToken | |
import com.google.gson.stream.JsonReader | |
import com.google.gson.stream.JsonWriter | |
import java.lang.reflect.ParameterizedType | |
import java.lang.reflect.Type | |
/** | |
* Provides a way to serialize and deserialize Jetpack Compose stateful objects | |
*/ | |
class StateTypAdapterFactory : TypeAdapterFactory { | |
override fun <T : Any> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? { | |
val cls = type.rawType | |
// Short circuit if this isn't a jetpack compose state object | |
if (!State::class.java.isAssignableFrom(cls)) { | |
return null | |
} | |
val typeParams: Array<Type> = (type.type as ParameterizedType).actualTypeArguments | |
val param = typeParams[0] | |
val delegate = gson.getAdapter(TypeToken.get(param)) | |
return StateTypeAdapter(delegate) as TypeAdapter<T> | |
} | |
} | |
// Is your mind blown? | |
class StateTypeAdapter<I, T, S : State<T>>(private val delegate: TypeAdapter<I>) : TypeAdapter<S>() where T : I { | |
override fun write(out: JsonWriter?, value: S) = delegate.write(out, value.value) | |
override fun read(reader: JsonReader): S { | |
return mutableStateOf(delegate.read(reader)) as S | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package nullable.pest_control | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.mutableStateOf | |
data class PestControlSettings( | |
val boatSelection: MutableState<BoatSelection> = mutableStateOf(BoatSelection.Automatic), | |
val playStrategy: MutableState<PlayStrategy> = mutableStateOf(PlayStrategy.AttackPortals), | |
val specAttackStrategy: MutableState<SpecAttackStrategy> = mutableStateOf(SpecAttackStrategy.Automatic), | |
val prayerStrategy: MutableState<PrayerStrategy> = mutableStateOf(PrayerStrategy.Automatic), | |
val worldStrategy: MutableState<WorldStrategy> = mutableStateOf(WorldStrategy.Automatic), | |
val customSpecAtkWeapon: MutableState<Int> = mutableStateOf(0), | |
val customSpecAtkWeaponPercent: MutableState<Int> = mutableStateOf(0), | |
val customWorld: MutableState<Int> = mutableStateOf(0), | |
) | |
enum class BoatSelection(val uiName: String) { | |
Automatic("Automatic"), | |
Novice("Novice"), | |
Intermediate("Intermediate"), | |
Veteran("Veteran"), | |
; | |
override fun toString(): String { | |
return uiName | |
} | |
} | |
enum class PlayStrategy(val uiName: String) { | |
AttackPortals("Attack Portals"), | |
DefendKnight("Defend Knight"), | |
; | |
override fun toString(): String { | |
return uiName | |
} | |
} | |
enum class SpecAttackStrategy(val uiName: String) { | |
Automatic("Automatic"), | |
Custom("Custom"), | |
; | |
override fun toString(): String { | |
return uiName | |
} | |
} | |
enum class PrayerStrategy(val uiName: String) { | |
Automatic("Automatic"), | |
QuickPrayers("Quick Prayers"), | |
; | |
override fun toString(): String { | |
return uiName | |
} | |
} | |
enum class WorldStrategy(val uiName: String) { | |
Automatic("Automatic"), | |
Custom("Custom"), | |
; | |
override fun toString(): String { | |
return uiName | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package nullable.pest_control.gui | |
import androidx.compose.material.Colors | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.runtime.Composable | |
private val LightColors = Colors( | |
primary = md_theme_light_primary, | |
primaryVariant = md_theme_light_onPrimaryContainer, | |
onPrimary = md_theme_light_onPrimary, | |
secondary = md_theme_light_secondary, | |
secondaryVariant = md_theme_light_onSecondaryContainer, | |
onSecondary = md_theme_light_onSecondary, | |
error = md_theme_light_error, | |
onError = md_theme_light_onError, | |
background = md_theme_light_background, | |
onBackground = md_theme_light_onBackground, | |
surface = md_theme_light_surface, | |
onSurface = md_theme_light_onSurface, | |
isLight = true, | |
) | |
private val DarkColors = Colors( | |
primary = md_theme_dark_primary, | |
onPrimary = md_theme_dark_onPrimary, | |
primaryVariant = md_theme_dark_onPrimaryContainer, | |
secondary = md_theme_dark_secondary, | |
onSecondary = md_theme_dark_onSecondary, | |
secondaryVariant = md_theme_dark_onSecondaryContainer, | |
error = md_theme_dark_error, | |
onError = md_theme_dark_onError, | |
background = md_theme_dark_background, | |
onBackground = md_theme_dark_onBackground, | |
surface = md_theme_dark_surface, | |
onSurface = md_theme_dark_onSurface, | |
isLight = false, | |
) | |
@Composable | |
fun AppTheme( | |
useDarkTheme: Boolean = true, | |
content: @Composable() () -> Unit | |
) { | |
val colors = if (!useDarkTheme) { | |
LightColors | |
} else { | |
DarkColors | |
} | |
MaterialTheme( | |
colors = colors, | |
content = content | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment