Skip to content

Instantly share code, notes, and snippets.

@Nullable-TB
Created September 2, 2022 23:42
Show Gist options
  • Save Nullable-TB/2ddff445f295208084bc946b2dc87480 to your computer and use it in GitHub Desktop.
Save Nullable-TB/2ddff445f295208084bc946b2dc87480 to your computer and use it in GitHub Desktop.
Jetpack Compose GUI example
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()
}
}
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() }
}
}
}
}
}
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
}
}
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
}
}
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