├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── kenkeremath │ │ └── uselessui │ │ └── app │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── kenkeremath │ │ │ └── uselessui │ │ │ └── app │ │ │ ├── MainActivity.kt │ │ │ ├── navigation │ │ │ └── NavRoutes.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── ui │ │ │ ├── components │ │ │ └── ParameterSlider.kt │ │ │ └── screens │ │ │ ├── shatter │ │ │ ├── ShatterPagerDemoScreen.kt │ │ │ └── ShatterableLayoutDemoScreen.kt │ │ │ └── waves │ │ │ └── WavesDemoScreen.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── kenkeremath │ └── uselessui │ └── ExampleUnitTest.kt ├── build-logic ├── convention │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── com │ │ └── seankenkeremath │ │ └── uselessui │ │ └── convention │ │ ├── MavenPublishConfigExtension.kt │ │ └── UselessUiLibMavenPublishConventionPlugin.kt └── settings.gradle.kts ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── distortion_box_demo.gif ├── shatterable_layout_demo.gif ├── shatterpager_demo.gif ├── waves_demo1.gif └── waves_demo2.gif ├── settings.gradle.kts ├── shatterable-layout ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── kenkeremath │ └── uselessui │ └── shatter │ ├── ShatterPager.kt │ ├── ShatterableLayout.kt │ ├── ShatteredImage.kt │ └── VoronoiTesellation.kt └── waves ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src ├── androidTest └── java │ └── com │ └── kenkeremath │ └── uselessui │ └── waves │ └── ExampleInstrumentedTest.kt ├── main ├── AndroidManifest.xml └── java │ └── com │ └── kenkeremath │ └── uselessui │ └── waves │ ├── DistortionBox.kt │ ├── WaveUtils.kt │ ├── WavyBox.kt │ └── WavyLine.kt └── test └── java └── com └── kenkeremath └── uselessui └── waves └── ExampleUnitTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .kotlin/ 4 | /local.properties 5 | /.idea 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | local.properties 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sean Kenkeremath 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤷‍♂️ Useless UI 🤷‍♂️ 2 | 3 | Useless UI is a Jetpack Compose library containing fun, but probably useless UI components. My goal is for each component module to be available as an independent Gradle dependency and also as a suite that can be imported as one dependency. This repo also contains a sample app to showcase these components. 4 | 5 | I will be adding to this over time as I get inspiration, tinker with things, and create components I want to reuse in other side projects. 6 | 7 | ## Components 8 | 9 | ### ShatterableLayout 10 | 11 | | ShatterableLayout | ShatterPager | 12 | | --- | --- | 13 | | | | 14 | 15 | This component allows its children to be shattered into many pieces. The exact properties of this shattering are configurable via `ShatterSpec`. `ShatterableLayout` captures a bitmap of its content (the timing of this can be controlled via `CaptureMode`) and then uses that for the shattering effect. The shattering is done using a Voronoi Diagram algorithm to create non-overlapping random polygons. I was inspired by the glass shattering transition in Powerpoint, which I recall fondly adding to all of my presentations despite being completely unnecessary and annoying. 16 | 17 | You can also do this in reverse if you want to "unshatter" something which looks neat. 18 | 19 | #### Installation 20 | [![](https://img.shields.io/maven-central/v/io.github.seankenkeremath/shatterable-layout)](https://search.maven.org/artifact/io.github.seankenkeremath/shatterable-layout) 21 | 22 | ``` 23 | dependencies { 24 | implementation("io.github.seankenkeremath:shatterable-layout:0.1.0") // Replace with latest version above 25 | } 26 | ``` 27 | 28 | #### Usage 29 | 30 | ##### ShatterableLayout 31 | 32 | ```kotlin 33 | // Basic usage 34 | ShatterableLayout( 35 | progress = shatterProgress, // 0f = intact, 1f = fully shattered 36 | modifier = Modifier.fillMaxWidth() 37 | ) { 38 | // Your content here 39 | Text("Example content") 40 | } 41 | 42 | // With more options 43 | ShatterableLayout( 44 | progress = shatterProgress, 45 | captureMode = CaptureMode.LAZY, 46 | shatterSpec = ShatterSpec(shardCount = 30, velocity = 300f), // ShatterSpec defines the properties of the shattering effect. There are many other parameters, but default values will be applied if not specified. 47 | modifier = Modifier.fillMaxWidth() 48 | ) { 49 | // Your content here 50 | } 51 | ``` 52 | 53 | ##### ShatterPager 54 | 55 | ```kotlin 56 | // Basic usage 57 | ShatterPager( 58 | state = pagerState, 59 | modifier = Modifier.fillMaxSize() 60 | ) { page -> 61 | // Your page content here 62 | Box(Modifier.fillMaxSize()) { 63 | Text("Page $page") 64 | } 65 | } 66 | 67 | // With custom shatter effect 68 | ShatterPager( 69 | state = pagerState, 70 | shatterSpec = ShatterSpec(shardCount = 20), 71 | modifier = Modifier.fillMaxSize() 72 | ) { page -> 73 | // Your page content here 74 | } 75 | ``` 76 | 77 | #### Key Classes and Functions 78 | 79 | ##### ShatterSpec 80 | Configures the shatter effect properties: 81 | ```kotlin 82 | ShatterSpec( 83 | shardCount: Int = 15, 84 | easing: Easing = FastOutSlowInEasing, 85 | velocity: Float = 300f, 86 | rotationXTarget: Float = 30f, 87 | rotationYTarget: Float = 30f, 88 | rotationZTarget: Float = 30f, 89 | velocityVariation: Float = 100f, 90 | rotationXVariation: Float = 10f, 91 | rotationYVariation: Float = 10f, 92 | rotationZVariation: Float = 10f, 93 | alphaTarget: Float = 0f, 94 | ) 95 | ``` 96 | 97 | ##### CaptureMode 98 | Controls when the underlying bitmap is captured for the shatter effect: 99 | - `CaptureMode.AUTO`: Automatically recaptures when content changes based on size or the `contentKey` param for `ShatterableLayout` 100 | - `CaptureMode.LAZY`: Only captures once shatter progress is > 0. This can be more memory efficient depending on your use case. 101 | 102 | #### Optimizations 103 | * Individual shards are rendered using cropped, smaller bitmaps to conserve memory 104 | * Bitmap capturing can be done lazily or immediately depending on your use case 105 | * The shattering animation of individual shards are performed at the graphics layer 106 | * Recompositions are minimized 107 | 108 | #### Future improvements + optimizations 109 | * We can avoid creating cropped bitmaps entirely if Compose supports either **1)** `graphicsLayer` animations on Canvas objects (we can crop the parent bitmap in the Canvas) or **2)** cropping inside `graphicsLayer` with an arbitrary path (only simple shapes are currently supported) 110 | * Capturing of the bitmap and creation of Voronoi cells can be offloaded to a background thread 111 | 112 | ### Wavy Components 113 | 114 | Note: In these screenshots the "jump" is just coming from the GIF repeating. 115 | These components are designed to seamlessly repeat the wave pattern. 116 | 117 | | | | | 118 | |------------------------------------------------|------------------------------------------------|--------------------------------------------------------| 119 | | | | | 120 | 121 | These components give you the building block to create wavy effects in your UI. 122 | That includes a normal `WavyLine` as well as a `WavyBox` where any combination of sides can be wavy. 123 | The waves are customizable via `WavySpec` which can easily be animated or controlled by the parent Composable. 124 | `WavyBox` supports several draw styles including via `Brush` or Color. 125 | 126 | This library also includes a `wavyPathSegment` function you can use in Path to draw a wave between 2 points for your own custom UI. 127 | 128 | #### Installation 129 | [![](https://img.shields.io/maven-central/v/io.github.seankenkeremath/waves)](https://search.maven.org/artifact/io.github.seankenkeremath/waves) 130 | 131 | ``` 132 | dependencies { 133 | implementation("io.github.seankenkeremath:waves:0.1.1") // Replace with latest version above 134 | } 135 | ``` 136 | 137 | #### Usage 138 | 139 | ##### WavyLine 140 | 141 | ```kotlin 142 | // Basic usage with animation managed by the component 143 | WavyLine( 144 | modifier = Modifier 145 | .fillMaxWidth() 146 | .height(50.dp), 147 | crestHeight = 8.dp, 148 | waveLength = 60.dp, 149 | color = Color.Blue, 150 | strokeWidth = 2.dp 151 | ) 152 | 153 | // With animation state-hoisted to allow control from the parent 154 | val animationProgress = remember { mutableStateOf(0f) } 155 | WavyLine( 156 | progress = animationProgress.value, 157 | modifier = Modifier 158 | .fillMaxWidth() 159 | .height(50.dp), 160 | crestHeight = 8.dp, 161 | waveLength = 60.dp, 162 | color = Color.Red, 163 | strokeWidth = 2.dp, 164 | centerWave = true 165 | ) 166 | ``` 167 | 168 | ##### WavyBox 169 | 170 | ```kotlin 171 | // Basic usage with animation managed by the component 172 | WavyBox( 173 | spec = WavyBoxSpec( 174 | topWavy = true, 175 | bottomWavy = true, 176 | leftWavy = false, 177 | rightWavy = false, 178 | crestHeight = 6.dp 179 | ), 180 | style = WavyBoxStyle.Outlined( 181 | strokeColor = Color.Black, 182 | strokeWidth = 2.dp 183 | ), 184 | modifier = Modifier.size(200.dp) 185 | ) { 186 | Text("Wavy Box") 187 | } 188 | 189 | // With animation state-hoisted to allow control from the parent 190 | val animationProgress = remember { mutableStateOf(0f) } 191 | WavyBox( 192 | progress = animationProgress.value, 193 | spec = WavyBoxSpec( 194 | topWavy = true, 195 | bottomWavy = true, 196 | leftWavy = false, 197 | rightWavy = false 198 | ), 199 | style = WavyBoxStyle.FilledWithColor( 200 | color = Color.Cyan, 201 | strokeWidth = 0.dp, 202 | strokeColor = Color.Transparent 203 | ), 204 | modifier = Modifier.size(200.dp) 205 | ) 206 | ``` 207 | 208 | ##### DistortionBox 209 | 210 | ```kotlin 211 | // Basic usage with animation managed by the component 212 | DistortionBox( 213 | modifier = Modifier 214 | .fillMaxWidth() 215 | .height(200.dp) 216 | ) { 217 | Box( 218 | modifier = Modifier 219 | .fillMaxSize() 220 | .background(MaterialTheme.colorScheme.primaryContainer) 221 | ) { 222 | Text( 223 | "Distorted Content", 224 | style = MaterialTheme.typography.headlineMedium, 225 | color = MaterialTheme.colorScheme.onPrimaryContainer, 226 | modifier = Modifier.align(Alignment.Center) 227 | ) 228 | } 229 | } 230 | 231 | // With animation state-hoisted to allow control from the parent 232 | val time = remember { mutableFloatStateOf(0f) } 233 | DistortionBoxImpl( 234 | time = time.value, 235 | modifier = Modifier 236 | .fillMaxWidth() 237 | .height(200.dp) 238 | ) { 239 | // Your content here 240 | } 241 | ``` 242 | 243 | Note: The `DistortionBox` component requires Android 13 (API 33/Tiramisu) or higher to work as it uses RuntimeShader. The component applies a wave distortion effect to its content using a custom shader. 244 | 245 | ##### Custom Path with Wavy Segment 246 | 247 | ```kotlin 248 | // Add a wavy segment to a path 249 | Path().apply { 250 | moveTo(startX, startY) 251 | wavyPathSegment( 252 | animationProgress = animatedPhase, 253 | crestHeightPx = 20f, 254 | waveLengthPx = 100f, 255 | startPoint = Offset(startX, startY), 256 | endPoint = Offset(endX, endY) 257 | ) 258 | } 259 | ``` 260 | 261 | #### Key Classes and Functions 262 | 263 | ##### wavyPathSegment 264 | Creates a wavy path between two points: 265 | ```kotlin 266 | // Basic usage 267 | wavyPathSegment( 268 | animationProgress = 0f, 269 | crestHeightPx = 10f, 270 | waveLengthPx = 50f, 271 | startPoint = Offset(0f, 0f), 272 | endPoint = Offset(100f, 0f) 273 | ) 274 | 275 | // With Dp values 276 | wavyPathSegment( 277 | animationProgress = 0f, 278 | crestHeight = 10.dp, 279 | waveLength = 50.dp, 280 | startPoint = Offset(0f, 0f), 281 | endPoint = Offset(100f, 0f), 282 | density = LocalDensity.current 283 | ) 284 | ``` 285 | 286 | #### Future improvements + optimizations 287 | * I plan to build in support for a corner radius which will make the corners for wavy box look a bit cleaner as separate wave segments converge -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.kotlin.compose) 5 | } 6 | 7 | android { 8 | namespace = "com.kenkeremath.uselessui.app" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.kenkeremath.uselessui.app" 13 | minSdk = 24 14 | targetSdk = 35 15 | versionCode = 1 16 | versionName = "1.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | isMinifyEnabled = false 24 | proguardFiles( 25 | getDefaultProguardFile("proguard-android-optimize.txt"), 26 | "proguard-rules.pro" 27 | ) 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.VERSION_11 32 | targetCompatibility = JavaVersion.VERSION_11 33 | } 34 | kotlinOptions { 35 | jvmTarget = "11" 36 | } 37 | buildFeatures { 38 | compose = true 39 | } 40 | } 41 | 42 | dependencies { 43 | implementation(project(":shatterable-layout")) 44 | implementation(project(":waves")) 45 | 46 | implementation(libs.androidx.core.ktx) 47 | implementation(libs.androidx.lifecycle.runtime.ktx) 48 | implementation(libs.androidx.activity.compose) 49 | implementation(platform(libs.androidx.compose.bom)) 50 | implementation(libs.androidx.ui) 51 | implementation(libs.androidx.ui.graphics) 52 | implementation(libs.androidx.ui.tooling.preview) 53 | implementation(libs.androidx.material3) 54 | implementation(libs.androidx.navigation.compose) 55 | testImplementation(libs.junit) 56 | androidTestImplementation(libs.androidx.junit) 57 | androidTestImplementation(libs.androidx.espresso.core) 58 | androidTestImplementation(platform(libs.androidx.compose.bom)) 59 | androidTestImplementation(libs.androidx.ui.test.junit4) 60 | debugImplementation(libs.androidx.ui.tooling) 61 | debugImplementation(libs.androidx.ui.test.manifest) 62 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/kenkeremath/uselessui/app/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.kenkeremath.uselessui.exampleapp", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.HorizontalDivider 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.IconButton 18 | import androidx.compose.material3.ListItem 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Scaffold 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.TopAppBar 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.unit.dp 27 | import androidx.navigation.compose.NavHost 28 | import androidx.navigation.compose.composable 29 | import androidx.navigation.compose.currentBackStackEntryAsState 30 | import androidx.navigation.compose.rememberNavController 31 | import com.kenkeremath.uselessui.app.navigation.NavRoutes 32 | import com.kenkeremath.uselessui.app.theme.UselessUITheme 33 | import com.kenkeremath.uselessui.app.ui.screens.shatter.ShatterPagerDemoScreen 34 | import com.kenkeremath.uselessui.app.ui.screens.shatter.ShatterableLayoutDemoScreen 35 | import com.kenkeremath.uselessui.app.ui.screens.waves.WavesDemoScreen 36 | 37 | class MainActivity : ComponentActivity() { 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | enableEdgeToEdge() 41 | setContent { 42 | UselessUITheme { 43 | MainScreen() 44 | } 45 | } 46 | } 47 | } 48 | 49 | @OptIn(ExperimentalMaterial3Api::class) 50 | @Composable 51 | fun MainScreen() { 52 | val navController = rememberNavController() 53 | val navBackStackEntry by navController.currentBackStackEntryAsState() 54 | val currentRoute = navBackStackEntry?.destination?.route 55 | 56 | Scaffold( 57 | topBar = { 58 | TopAppBar( 59 | title = { Text(text = getScreenTitle(currentRoute)) }, 60 | navigationIcon = { 61 | if (currentRoute != NavRoutes.DemoList.route) { 62 | IconButton(onClick = { navController.navigateUp() }) { 63 | Icon( 64 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 65 | contentDescription = "Back" 66 | ) 67 | } 68 | } 69 | } 70 | ) 71 | } 72 | ) { innerPadding -> 73 | NavHost( 74 | navController = navController, 75 | startDestination = NavRoutes.DemoList.route, 76 | modifier = Modifier.padding(innerPadding) 77 | ) { 78 | composable(NavRoutes.DemoList.route) { 79 | DemoListScreen( 80 | onDemoSelected = { route -> navController.navigate(route) } 81 | ) 82 | } 83 | composable(NavRoutes.ShatterableLayoutDemo.route) { 84 | ShatterableLayoutDemoScreen() 85 | } 86 | composable(NavRoutes.ShatterPagerDemo.route) { 87 | ShatterPagerDemoScreen() 88 | } 89 | composable(NavRoutes.WavesDemo.route) { 90 | WavesDemoScreen() 91 | } 92 | } 93 | } 94 | } 95 | 96 | @Composable 97 | fun DemoListScreen( 98 | onDemoSelected: (String) -> Unit, 99 | modifier: Modifier = Modifier 100 | ) { 101 | Column( 102 | modifier = modifier 103 | .fillMaxSize() 104 | .padding(16.dp) 105 | ) { 106 | Text( 107 | text = "Select a Demo", 108 | style = MaterialTheme.typography.headlineMedium, 109 | modifier = Modifier.padding(bottom = 16.dp) 110 | ) 111 | 112 | ListItem( 113 | headlineContent = { Text("ShatterableLayout Demo") }, 114 | supportingContent = { Text("Tap to break content into pieces") }, 115 | modifier = Modifier 116 | .fillMaxWidth() 117 | .clickable { onDemoSelected(NavRoutes.ShatterableLayoutDemo.route) } 118 | ) 119 | 120 | HorizontalDivider() 121 | 122 | ListItem( 123 | headlineContent = { Text("ShatterPager Demo") }, 124 | supportingContent = { Text("Swipe between pages with shatter effect") }, 125 | modifier = Modifier 126 | .fillMaxWidth() 127 | .clickable { onDemoSelected(NavRoutes.ShatterPagerDemo.route) } 128 | ) 129 | 130 | HorizontalDivider() 131 | 132 | ListItem( 133 | headlineContent = { Text("Wave Components Demo") }, 134 | supportingContent = { Text("Animated wave-based UI components") }, 135 | modifier = Modifier 136 | .fillMaxWidth() 137 | .clickable { onDemoSelected(NavRoutes.WavesDemo.route) } 138 | ) 139 | } 140 | } 141 | 142 | /** 143 | * Returns the screen title based on the current route 144 | */ 145 | private fun getScreenTitle(route: String?): String { 146 | return when (route) { 147 | NavRoutes.DemoList.route -> "Useless UI Demos" 148 | NavRoutes.ShatterableLayoutDemo.route -> "ShatterableLayout Demo" 149 | NavRoutes.ShatterPagerDemo.route -> "ShatterPager Demo" 150 | NavRoutes.WavesDemo.route -> "Wave Components Demo" 151 | else -> "Useless UI Demos" 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/navigation/NavRoutes.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.navigation 2 | 3 | /** 4 | * Navigation routes for the app 5 | */ 6 | sealed class NavRoutes(val route: String) { 7 | object DemoList : NavRoutes("demo_list") 8 | object ShatterableLayoutDemo : NavRoutes("shatterable_layout_demo") 9 | object ShatterPagerDemo : NavRoutes("shatter_pager_demo") 10 | object WavesDemo : NavRoutes("waves_demo") 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val DarkColorScheme = darkColorScheme( 14 | primary = Purple80, 15 | secondary = PurpleGrey80, 16 | tertiary = Pink80 17 | ) 18 | 19 | private val LightColorScheme = lightColorScheme( 20 | primary = Purple40, 21 | secondary = PurpleGrey40, 22 | tertiary = Pink40 23 | 24 | /* Other default colors to override 25 | background = Color(0xFFFFFBFE), 26 | surface = Color(0xFFFFFBFE), 27 | onPrimary = Color.White, 28 | onSecondary = Color.White, 29 | onTertiary = Color.White, 30 | onBackground = Color(0xFF1C1B1F), 31 | onSurface = Color(0xFF1C1B1F), 32 | */ 33 | ) 34 | 35 | @Composable 36 | fun UselessUITheme( 37 | darkTheme: Boolean = isSystemInDarkTheme(), 38 | // Dynamic color is available on Android 12+ 39 | dynamicColor: Boolean = true, 40 | content: @Composable () -> Unit 41 | ) { 42 | val colorScheme = when { 43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 44 | val context = LocalContext.current 45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 46 | } 47 | 48 | darkTheme -> DarkColorScheme 49 | else -> LightColorScheme 50 | } 51 | 52 | MaterialTheme( 53 | colorScheme = colorScheme, 54 | typography = Typography, 55 | content = content 56 | ) 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/ui/components/ParameterSlider.kt: -------------------------------------------------------------------------------- 1 | import android.annotation.SuppressLint 2 | import androidx.compose.foundation.layout.Arrangement 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material3.Slider 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Modifier 11 | import java.math.RoundingMode 12 | import java.text.DecimalFormat 13 | 14 | @SuppressLint("DefaultLocale") 15 | @Composable 16 | internal fun ParameterSlider( 17 | label: String, 18 | value: Float, 19 | onValueChange: (Float) -> Unit, 20 | valueRange: ClosedFloatingPointRange, 21 | modifier: Modifier = Modifier, 22 | showAsInt: Boolean = false, 23 | ) { 24 | 25 | val intFormat = remember { 26 | val df = DecimalFormat("#") 27 | df.roundingMode = RoundingMode.CEILING 28 | df 29 | } 30 | Column(modifier = modifier.fillMaxWidth()) { 31 | Row( 32 | modifier = Modifier.fillMaxWidth(), 33 | horizontalArrangement = Arrangement.SpaceBetween 34 | ) { 35 | Text(text = label) 36 | val formattedValue = if (!showAsInt) { 37 | String.format("%.1f", value) 38 | } else { 39 | intFormat.format(value) 40 | } 41 | Text(text = formattedValue) 42 | } 43 | Slider( 44 | value = value, 45 | onValueChange = onValueChange, 46 | valueRange = valueRange, 47 | modifier = Modifier.fillMaxWidth() 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/ui/screens/shatter/ShatterPagerDemoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.ui.screens.shatter 2 | 3 | import ParameterSlider 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.pager.rememberPagerState 15 | import androidx.compose.foundation.rememberScrollState 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material3.Card 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.OutlinedButton 20 | import androidx.compose.material3.Switch 21 | import androidx.compose.material3.Text 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.runtime.mutableFloatStateOf 25 | import androidx.compose.runtime.mutableIntStateOf 26 | import androidx.compose.runtime.mutableStateOf 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.graphics.Color 32 | import androidx.compose.ui.text.style.TextAlign 33 | import androidx.compose.ui.unit.dp 34 | import androidx.compose.ui.util.fastRoundToInt 35 | import com.kenkeremath.uselessui.shatter.ShatterPager 36 | import com.kenkeremath.uselessui.shatter.ShatterSpec 37 | 38 | @Composable 39 | fun ShatterPagerDemoScreen(modifier: Modifier = Modifier) { 40 | var showCenterPoints by remember { mutableStateOf(false) } 41 | var shardCount by remember { mutableIntStateOf(15) } 42 | var velocity by remember { mutableFloatStateOf(300f) } 43 | var rotationX by remember { mutableFloatStateOf(30f) } 44 | var rotationY by remember { mutableFloatStateOf(30f) } 45 | var rotationZ by remember { mutableFloatStateOf(30f) } 46 | var alphaTarget by remember { mutableFloatStateOf(0.3f) } 47 | var pageSpacing by remember { mutableFloatStateOf(8f) } 48 | 49 | val shatterSpec = remember(shardCount, velocity, rotationX, rotationY, rotationZ, alphaTarget) { 50 | ShatterSpec( 51 | shardCount = shardCount, 52 | velocity = velocity, 53 | rotationXTarget = rotationX, 54 | rotationYTarget = rotationY, 55 | rotationZTarget = rotationZ, 56 | alphaTarget = alphaTarget 57 | ) 58 | } 59 | 60 | Column( 61 | modifier = modifier 62 | .fillMaxSize() 63 | .verticalScroll(rememberScrollState()) 64 | .padding(16.dp), 65 | horizontalAlignment = Alignment.CenterHorizontally, 66 | verticalArrangement = Arrangement.spacedBy(16.dp) 67 | ) { 68 | Text( 69 | text = "Swipe between pages to see the shatter effect", 70 | style = MaterialTheme.typography.headlineSmall, 71 | textAlign = TextAlign.Center, 72 | modifier = Modifier.padding(16.dp) 73 | ) 74 | 75 | val pagerState = rememberPagerState { 5 } 76 | 77 | ShatterPager( 78 | state = pagerState, 79 | shatterSpec = shatterSpec, 80 | showCenterPoints = showCenterPoints, 81 | contentPadding = PaddingValues(horizontal = 64.dp), 82 | beyondViewportPageCount = 3, 83 | pageSpacing = pageSpacing.dp, 84 | modifier = Modifier 85 | .fillMaxWidth() 86 | .height(300.dp) 87 | ) { page -> 88 | Card( 89 | modifier = Modifier 90 | .fillMaxSize() 91 | .padding(8.dp) 92 | ) { 93 | Box( 94 | modifier = Modifier 95 | .fillMaxSize() 96 | .background(getColorForPage(page)), 97 | contentAlignment = Alignment.Center 98 | ) { 99 | Text( 100 | text = "Page ${page + 1}", 101 | style = MaterialTheme.typography.headlineLarge, 102 | color = Color.White 103 | ) 104 | } 105 | } 106 | } 107 | 108 | Spacer(modifier = Modifier.height(16.dp)) 109 | 110 | Column( 111 | modifier = Modifier.fillMaxWidth(), 112 | verticalArrangement = Arrangement.spacedBy(8.dp) 113 | ) { 114 | SwitchSetting( 115 | label = "Show Center Points", 116 | checked = showCenterPoints, 117 | onCheckedChange = { showCenterPoints = it } 118 | ) 119 | 120 | ParameterSlider( 121 | label = "Page Spacing", 122 | value = pageSpacing, 123 | onValueChange = { pageSpacing = it }, 124 | valueRange = 0f..32f 125 | ) 126 | 127 | ParameterSlider( 128 | label = "Number of shards", 129 | value = shardCount.toFloat(), 130 | onValueChange = { shardCount = it.toInt() }, 131 | valueRange = 5f..100f, 132 | showAsInt = true, 133 | ) 134 | 135 | ParameterSlider( 136 | label = "Velocity", 137 | value = velocity, 138 | onValueChange = { velocity = it }, 139 | valueRange = 0f..2000f 140 | ) 141 | 142 | ParameterSlider( 143 | label = "Rotation X", 144 | value = rotationX, 145 | onValueChange = { rotationX = it }, 146 | valueRange = -180f..180f 147 | ) 148 | 149 | ParameterSlider( 150 | label = "Rotation Y", 151 | value = rotationY, 152 | onValueChange = { rotationY = it }, 153 | valueRange = -180f..180f 154 | ) 155 | 156 | ParameterSlider( 157 | label = "Rotation Z", 158 | value = rotationZ, 159 | onValueChange = { rotationZ = it }, 160 | valueRange = -180f..180f 161 | ) 162 | 163 | ParameterSlider( 164 | label = "Alpha", 165 | value = alphaTarget, 166 | onValueChange = { alphaTarget = it }, 167 | valueRange = 0f..1f 168 | ) 169 | 170 | OutlinedButton( 171 | onClick = { 172 | shardCount = 15 173 | velocity = 300f 174 | rotationX = 30f 175 | rotationY = 30f 176 | rotationZ = 30f 177 | alphaTarget = 0.3f 178 | pageSpacing = 8f 179 | }, 180 | modifier = Modifier 181 | .align(Alignment.CenterHorizontally) 182 | .padding(top = 8.dp) 183 | ) { 184 | Text("Reset Parameters") 185 | } 186 | } 187 | 188 | Spacer(modifier = Modifier.height(32.dp)) 189 | } 190 | } 191 | 192 | @Composable 193 | private fun SwitchSetting( 194 | label: String, 195 | checked: Boolean, 196 | onCheckedChange: (Boolean) -> Unit, 197 | modifier: Modifier = Modifier 198 | ) { 199 | Box( 200 | modifier = modifier 201 | .fillMaxWidth() 202 | .padding(vertical = 8.dp), 203 | ) { 204 | Text( 205 | text = label, 206 | style = MaterialTheme.typography.bodyLarge, 207 | modifier = Modifier.align(Alignment.CenterStart) 208 | ) 209 | Switch( 210 | checked = checked, 211 | onCheckedChange = onCheckedChange, 212 | modifier = Modifier.align(Alignment.CenterEnd) 213 | ) 214 | } 215 | } 216 | 217 | private fun getColorForPage(page: Int): Color { 218 | return when (page % 5) { 219 | 0 -> Color(0xFF1976D2) 220 | 1 -> Color(0xFF388E3C) 221 | 2 -> Color(0xFFD32F2F) 222 | 3 -> Color(0xFF7B1FA2) 223 | else -> Color(0xFFFF9800) 224 | } 225 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/ui/screens/shatter/ShatterableLayoutDemoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.ui.screens.shatter 2 | 3 | import ParameterSlider 4 | import androidx.compose.animation.core.FastOutSlowInEasing 5 | import androidx.compose.animation.core.animateFloatAsState 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.foundation.layout.Arrangement 11 | import androidx.compose.foundation.layout.Box 12 | import androidx.compose.foundation.layout.Column 13 | import androidx.compose.foundation.layout.Row 14 | import androidx.compose.foundation.layout.Spacer 15 | import androidx.compose.foundation.layout.fillMaxSize 16 | import androidx.compose.foundation.layout.fillMaxWidth 17 | import androidx.compose.foundation.layout.height 18 | import androidx.compose.foundation.layout.padding 19 | import androidx.compose.foundation.layout.size 20 | import androidx.compose.foundation.rememberScrollState 21 | import androidx.compose.foundation.verticalScroll 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.OutlinedButton 24 | import androidx.compose.material3.Switch 25 | import androidx.compose.material3.Text 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.runtime.getValue 28 | import androidx.compose.runtime.mutableFloatStateOf 29 | import androidx.compose.runtime.mutableIntStateOf 30 | import androidx.compose.runtime.mutableLongStateOf 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.setValue 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.geometry.Offset 37 | import androidx.compose.ui.input.pointer.pointerInput 38 | import androidx.compose.ui.text.style.TextAlign 39 | import androidx.compose.ui.tooling.preview.Preview 40 | import androidx.compose.ui.unit.dp 41 | import com.kenkeremath.uselessui.app.theme.UselessUITheme 42 | import com.kenkeremath.uselessui.shatter.CaptureMode 43 | import com.kenkeremath.uselessui.shatter.ShatterSpec 44 | import com.kenkeremath.uselessui.shatter.ShatterableLayout 45 | 46 | @Composable 47 | fun ShatterableLayoutDemoScreen(modifier: Modifier = Modifier) { 48 | var targetProgress by remember { mutableFloatStateOf(0f) } 49 | var showCenterPoints by remember { mutableStateOf(false) } 50 | 51 | val defaultDuration = 1000L 52 | val defaultShardCount = 15 53 | val defaultVelocity = 300f 54 | val defaultRotationX = 30f 55 | val defaultRotationY = 30f 56 | val defaultRotationZ = 30f 57 | val defaultAlphaTarget = 0.3f 58 | 59 | var durationMillis by remember { mutableLongStateOf(defaultDuration) } 60 | var shardCount by remember { mutableIntStateOf(defaultShardCount) } 61 | var velocity by remember { mutableFloatStateOf(defaultVelocity) } 62 | var rotationX by remember { mutableFloatStateOf(defaultRotationX) } 63 | var rotationY by remember { mutableFloatStateOf(defaultRotationY) } 64 | var rotationZ by remember { mutableFloatStateOf(defaultRotationZ) } 65 | var alphaTarget by remember { mutableFloatStateOf(defaultAlphaTarget) } 66 | 67 | fun resetParameters() { 68 | durationMillis = defaultDuration 69 | shardCount = defaultShardCount 70 | velocity = defaultVelocity 71 | rotationX = defaultRotationX 72 | rotationY = defaultRotationY 73 | rotationZ = defaultRotationZ 74 | alphaTarget = defaultAlphaTarget 75 | } 76 | 77 | val shatterSpec = 78 | remember( 79 | shardCount, 80 | velocity, 81 | rotationX, 82 | rotationY, 83 | rotationZ, 84 | alphaTarget 85 | ) { 86 | ShatterSpec( 87 | shardCount = shardCount, 88 | easing = FastOutSlowInEasing, 89 | velocity = velocity, 90 | rotationXTarget = rotationX, 91 | rotationYTarget = rotationY, 92 | rotationZTarget = rotationZ, 93 | alphaTarget = alphaTarget 94 | ) 95 | } 96 | 97 | Column( 98 | modifier = modifier 99 | .fillMaxSize() 100 | .verticalScroll(rememberScrollState()) 101 | .padding(16.dp), 102 | horizontalAlignment = Alignment.CenterHorizontally, 103 | verticalArrangement = Arrangement.spacedBy(16.dp) 104 | ) { 105 | Text( 106 | text = "Tap the image to shatter, tap again to reverse", 107 | style = MaterialTheme.typography.headlineLarge, 108 | textAlign = TextAlign.Center, 109 | modifier = Modifier.padding(16.dp) 110 | ) 111 | 112 | val interactionSource = remember { MutableInteractionSource() } 113 | var impactOffset by remember { mutableStateOf(Offset.Unspecified) } 114 | var animating by remember { mutableStateOf(false) } 115 | var shattering by remember { mutableStateOf(false) } 116 | val progress by animateFloatAsState( 117 | targetValue = targetProgress, 118 | animationSpec = tween( 119 | durationMillis = durationMillis.toInt(), 120 | easing = shatterSpec.easing 121 | ), 122 | label = "shatter", 123 | finishedListener = { _ -> 124 | animating = false 125 | } 126 | ) 127 | 128 | ShatterableLayout( 129 | captureMode = CaptureMode.LAZY, 130 | progress = progress, 131 | shatterCenter = impactOffset, 132 | shatterSpec = shatterSpec, 133 | showCenterPoints = showCenterPoints, 134 | modifier = Modifier 135 | .pointerInput(Unit) { 136 | awaitPointerEventScope { 137 | while (true) { 138 | val event = awaitPointerEvent() 139 | val change = event.changes.firstOrNull() 140 | if (change != null 141 | && !animating && change.pressed 142 | && progress == 0f 143 | ) { 144 | impactOffset = change.position 145 | } 146 | } 147 | } 148 | } 149 | .clickable( 150 | interactionSource = interactionSource, 151 | indication = null 152 | ) { 153 | shattering = !shattering 154 | targetProgress = if (shattering) 1f else 0f 155 | animating = true 156 | } 157 | ) { 158 | Box( 159 | modifier = Modifier 160 | .size(200.dp) 161 | .background(MaterialTheme.colorScheme.primary), 162 | contentAlignment = Alignment.Center 163 | ) { 164 | Text( 165 | text = "Tap to break!", 166 | color = MaterialTheme.colorScheme.onPrimary, 167 | style = MaterialTheme.typography.headlineMedium 168 | ) 169 | } 170 | } 171 | 172 | Spacer(modifier = Modifier.height(16.dp)) 173 | 174 | Row( 175 | modifier = Modifier 176 | .fillMaxWidth() 177 | .padding(vertical = 8.dp), 178 | horizontalArrangement = Arrangement.SpaceBetween, 179 | verticalAlignment = Alignment.CenterVertically 180 | ) { 181 | Text( 182 | text = "Show Center Points", 183 | style = MaterialTheme.typography.bodyLarge 184 | ) 185 | Switch( 186 | checked = showCenterPoints, 187 | onCheckedChange = { showCenterPoints = it } 188 | ) 189 | } 190 | 191 | Spacer(modifier = Modifier.padding(vertical = 8.dp)) 192 | 193 | ParameterSlider( 194 | label = "Duration", 195 | value = durationMillis.toFloat(), 196 | onValueChange = { durationMillis = it.toLong() }, 197 | valueRange = 0f..5000f 198 | ) 199 | 200 | ParameterSlider( 201 | label = "Number of shards", 202 | value = shardCount.toFloat(), 203 | onValueChange = { shardCount = it.toInt() }, 204 | valueRange = 0f..100f, 205 | showAsInt = true, 206 | ) 207 | 208 | ParameterSlider( 209 | label = "Velocity", 210 | value = velocity, 211 | onValueChange = { velocity = it }, 212 | valueRange = 0f..2000f 213 | ) 214 | 215 | ParameterSlider( 216 | label = "Rotation X", 217 | value = rotationX, 218 | onValueChange = { rotationX = it }, 219 | valueRange = -180f..180f 220 | ) 221 | 222 | ParameterSlider( 223 | label = "Rotation Y", 224 | value = rotationY, 225 | onValueChange = { rotationY = it }, 226 | valueRange = -180f..180f 227 | ) 228 | 229 | ParameterSlider( 230 | label = "Rotation Z", 231 | value = rotationZ, 232 | onValueChange = { rotationZ = it }, 233 | valueRange = -180f..180f 234 | ) 235 | 236 | ParameterSlider( 237 | label = "Alpha", 238 | value = alphaTarget, 239 | onValueChange = { alphaTarget = it }, 240 | valueRange = 0f..1f 241 | ) 242 | 243 | OutlinedButton( 244 | onClick = { resetParameters() }, 245 | modifier = Modifier.padding(top = 8.dp) 246 | ) { 247 | Text("Reset Parameters") 248 | } 249 | 250 | Spacer(modifier = Modifier.height(32.dp)) 251 | } 252 | } 253 | 254 | @Preview(showBackground = true) 255 | @Composable 256 | fun ShatterableLayoutDemoScreenPreview() { 257 | UselessUITheme { 258 | ShatterableLayoutDemoScreen() 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /app/src/main/java/com/kenkeremath/uselessui/app/ui/screens/waves/WavesDemoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.kenkeremath.uselessui.app.ui.screens.waves 2 | 3 | import android.os.Build 4 | import androidx.compose.animation.core.LinearEasing 5 | import androidx.compose.animation.core.RepeatMode 6 | import androidx.compose.animation.core.animateFloat 7 | import androidx.compose.animation.core.infiniteRepeatable 8 | import androidx.compose.animation.core.rememberInfiniteTransition 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.foundation.background 11 | import androidx.compose.foundation.layout.Box 12 | import androidx.compose.foundation.layout.Column 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.layout.size 19 | import androidx.compose.foundation.rememberScrollState 20 | import androidx.compose.foundation.verticalScroll 21 | import androidx.compose.material3.Card 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Surface 24 | import androidx.compose.material3.Text 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.ui.Alignment 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.geometry.Offset 31 | import androidx.compose.ui.graphics.Brush 32 | import androidx.compose.ui.graphics.Color 33 | import androidx.compose.ui.text.style.TextAlign 34 | import androidx.compose.ui.tooling.preview.Preview 35 | import androidx.compose.ui.unit.dp 36 | import com.kenkeremath.uselessui.waves.WavyBox 37 | import com.kenkeremath.uselessui.waves.WavyLine 38 | import com.kenkeremath.uselessui.waves.WavyBoxSpec 39 | import com.kenkeremath.uselessui.waves.WavyBoxStyle 40 | import androidx.compose.ui.draw.alpha 41 | import com.kenkeremath.uselessui.waves.DistortionBox 42 | 43 | @Composable 44 | fun WavesDemoScreen() { 45 | Column( 46 | modifier = Modifier 47 | .fillMaxSize() 48 | .verticalScroll(rememberScrollState()) 49 | .padding(16.dp) 50 | ) { 51 | Text( 52 | text = "Wave Components", 53 | style = MaterialTheme.typography.headlineMedium, 54 | modifier = Modifier.padding(bottom = 16.dp) 55 | ) 56 | 57 | // Wavy Line Section 58 | DemoSection(title = "WavyLine") { 59 | Column { 60 | Text("Basic Wavy Line") 61 | Spacer(modifier = Modifier.height(8.dp)) 62 | Box( 63 | modifier = Modifier 64 | .fillMaxWidth() 65 | .height(60.dp) 66 | .padding(vertical = 8.dp) 67 | ) { 68 | WavyLine( 69 | modifier = Modifier.fillMaxSize(), 70 | color = MaterialTheme.colorScheme.primary 71 | ) 72 | } 73 | 74 | Spacer(modifier = Modifier.height(16.dp)) 75 | 76 | Text("Centered Wavy Line with Custom Parameters") 77 | Spacer(modifier = Modifier.height(8.dp)) 78 | Box( 79 | modifier = Modifier 80 | .fillMaxWidth() 81 | .height(60.dp) 82 | .padding(vertical = 8.dp) 83 | ) { 84 | WavyLine( 85 | modifier = Modifier.fillMaxSize(), 86 | centerWave = true, 87 | crestHeight = 4.dp, 88 | waveLength = 30.dp, 89 | strokeWidth = 3.dp, 90 | color = MaterialTheme.colorScheme.secondary, 91 | animationDurationMillis = 1500 92 | ) 93 | } 94 | } 95 | } 96 | 97 | // Gradient Wave Effect Section 98 | DemoSection(title = "Gradient Wave Effect") { 99 | Column { 100 | Text("Gradient Wave") 101 | Spacer(modifier = Modifier.height(8.dp)) 102 | Box( 103 | modifier = Modifier 104 | .fillMaxWidth() 105 | .height(80.dp) 106 | .padding(vertical = 8.dp) 107 | ) { 108 | val gradientBrush = remember { 109 | Brush.linearGradient( 110 | colors = listOf(Color.Cyan, Color.Blue), 111 | start = Offset(0f, Float.POSITIVE_INFINITY), 112 | end = Offset(0f, 0f) 113 | ) 114 | } 115 | 116 | WavyBox( 117 | spec = WavyBoxSpec( 118 | topWavy = true, 119 | rightWavy = false, 120 | bottomWavy = false, 121 | leftWavy = false, 122 | crestHeight = 6.dp, 123 | waveLength = 60.dp 124 | ), 125 | style = WavyBoxStyle.FilledWithBrush( 126 | brush = gradientBrush, 127 | strokeWidth = 0.dp, 128 | strokeColor = Color.Transparent 129 | ), 130 | modifier = Modifier.fillMaxSize() 131 | ) 132 | } 133 | } 134 | } 135 | 136 | // DistortionBox Section 137 | DemoSection(title = "DistortionBox") { 138 | Column { 139 | Text("Shader-based Wave Distortion") 140 | Spacer(modifier = Modifier.height(8.dp)) 141 | Box( 142 | modifier = Modifier 143 | .fillMaxWidth() 144 | .height(200.dp) 145 | .padding(vertical = 8.dp) 146 | ) { 147 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 148 | DistortionBox( 149 | modifier = Modifier 150 | .fillMaxSize() 151 | .alpha(0.5f) 152 | ) { 153 | Box( 154 | modifier = Modifier 155 | .fillMaxSize() 156 | .background(MaterialTheme.colorScheme.primaryContainer) 157 | ) { 158 | Text( 159 | "Distorted Content", 160 | style = MaterialTheme.typography.headlineMedium, 161 | color = MaterialTheme.colorScheme.onPrimaryContainer, 162 | modifier = Modifier.align(Alignment.Center) 163 | ) 164 | } 165 | } 166 | } else { 167 | Surface( 168 | modifier = Modifier.fillMaxSize(), 169 | color = MaterialTheme.colorScheme.surfaceVariant 170 | ) { 171 | Column( 172 | modifier = Modifier 173 | .fillMaxSize() 174 | .padding(16.dp), 175 | horizontalAlignment = Alignment.CenterHorizontally, 176 | verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center 177 | ) { 178 | Text( 179 | text = "RuntimeShader Not Supported", 180 | style = MaterialTheme.typography.titleLarge, 181 | textAlign = TextAlign.Center 182 | ) 183 | Text( 184 | text = "This effect requires Android 13 (API 33) or higher", 185 | style = MaterialTheme.typography.bodyMedium, 186 | textAlign = TextAlign.Center, 187 | modifier = Modifier.padding(top = 8.dp) 188 | ) 189 | } 190 | } 191 | } 192 | } 193 | } 194 | } 195 | 196 | // Wavy Box Section 197 | DemoSection(title = "WavyBox") { 198 | Column( 199 | modifier = Modifier.fillMaxWidth(), 200 | horizontalAlignment = Alignment.CenterHorizontally 201 | ) { 202 | Text("Horizontal Waves Only (Top & Bottom)") 203 | Spacer(modifier = Modifier.height(8.dp)) 204 | Box( 205 | modifier = Modifier 206 | .size(180.dp) 207 | .padding(vertical = 8.dp) 208 | ) { 209 | WavyBox( 210 | spec = WavyBoxSpec( 211 | topWavy = true, 212 | rightWavy = false, 213 | bottomWavy = true, 214 | leftWavy = false, 215 | crestHeight = 6.dp, 216 | ), 217 | style = WavyBoxStyle.Outlined( 218 | strokeWidth = 2.dp, 219 | strokeColor = MaterialTheme.colorScheme.primary 220 | ), 221 | modifier = Modifier.fillMaxSize(), 222 | ) { 223 | Text( 224 | "Horizontal\nWaves", 225 | textAlign = TextAlign.Center 226 | ) 227 | } 228 | } 229 | 230 | Spacer(modifier = Modifier.height(16.dp)) 231 | 232 | Text("Vertical Waves Only (Left & Right)") 233 | Spacer(modifier = Modifier.height(8.dp)) 234 | Box( 235 | modifier = Modifier 236 | .size(180.dp) 237 | .padding(vertical = 8.dp) 238 | ) { 239 | WavyBox( 240 | spec = WavyBoxSpec( 241 | topWavy = false, 242 | rightWavy = true, 243 | bottomWavy = false, 244 | leftWavy = true, 245 | crestHeight = 6.dp, 246 | ), 247 | style = WavyBoxStyle.FilledWithColor( 248 | strokeWidth = 2.dp, 249 | strokeColor = MaterialTheme.colorScheme.tertiary, 250 | color = MaterialTheme.colorScheme.tertiaryContainer 251 | ), 252 | modifier = Modifier.fillMaxSize(), 253 | ) { 254 | Text( 255 | "Vertical\nWaves", 256 | textAlign = TextAlign.Center, 257 | color = MaterialTheme.colorScheme.onTertiaryContainer 258 | ) 259 | } 260 | } 261 | 262 | Spacer(modifier = Modifier.height(16.dp)) 263 | 264 | Text("All Sides Wavy") 265 | Spacer(modifier = Modifier.height(8.dp)) 266 | Box( 267 | modifier = Modifier 268 | .size(180.dp) 269 | .padding(vertical = 8.dp) 270 | ) { 271 | WavyBox( 272 | spec = WavyBoxSpec( 273 | topWavy = true, 274 | rightWavy = true, 275 | bottomWavy = true, 276 | leftWavy = true, 277 | crestHeight = 4.dp, 278 | waveLength = 30.dp 279 | ), 280 | style = WavyBoxStyle.FilledWithColor( 281 | strokeWidth = 2.dp, 282 | strokeColor = MaterialTheme.colorScheme.tertiary, 283 | color = MaterialTheme.colorScheme.tertiaryContainer 284 | ), 285 | modifier = Modifier.fillMaxSize(), 286 | animationDurationMillis = 1500 287 | ) { 288 | Text( 289 | "All Sides\nWavy", 290 | textAlign = TextAlign.Center, 291 | style = MaterialTheme.typography.titleMedium, 292 | color = MaterialTheme.colorScheme.onTertiaryContainer 293 | ) 294 | } 295 | } 296 | 297 | Spacer(modifier = Modifier.height(16.dp)) 298 | 299 | Text("Alternating Sides (Top & Left)") 300 | Spacer(modifier = Modifier.height(8.dp)) 301 | Box( 302 | modifier = Modifier 303 | .size(180.dp) 304 | .padding(vertical = 8.dp) 305 | ) { 306 | WavyBox( 307 | spec = WavyBoxSpec( 308 | topWavy = true, 309 | rightWavy = false, 310 | bottomWavy = false, 311 | leftWavy = true, 312 | crestHeight = 6.dp, 313 | waveLength = 40.dp 314 | ), 315 | style = WavyBoxStyle.FilledWithColor( 316 | strokeWidth = 2.dp, 317 | strokeColor = MaterialTheme.colorScheme.tertiary, 318 | color = MaterialTheme.colorScheme.tertiaryContainer, 319 | ), 320 | modifier = Modifier.fillMaxSize(), 321 | ) { 322 | Text( 323 | "Top & Left\nWavy", 324 | textAlign = TextAlign.Center, 325 | color = MaterialTheme.colorScheme.onTertiaryContainer 326 | ) 327 | } 328 | } 329 | 330 | Spacer(modifier = Modifier.height(16.dp)) 331 | 332 | Text("Alternating Sides (Bottom & Right)") 333 | Spacer(modifier = Modifier.height(8.dp)) 334 | Box( 335 | modifier = Modifier 336 | .size(180.dp) 337 | .padding(vertical = 8.dp) 338 | ) { 339 | WavyBox( 340 | spec = WavyBoxSpec( 341 | topWavy = false, 342 | rightWavy = true, 343 | bottomWavy = true, 344 | leftWavy = false, 345 | crestHeight = 6.dp, 346 | waveLength = 40.dp 347 | ), 348 | style = WavyBoxStyle.FilledWithColor( 349 | strokeWidth = 2.dp, 350 | strokeColor = MaterialTheme.colorScheme.tertiary, 351 | color = MaterialTheme.colorScheme.tertiaryContainer 352 | ), 353 | modifier = Modifier.fillMaxSize(), 354 | ) { 355 | Text( 356 | "Bottom & Right\nWavy", 357 | textAlign = TextAlign.Center, 358 | color = MaterialTheme.colorScheme.onTertiaryContainer 359 | ) 360 | } 361 | } 362 | 363 | Spacer(modifier = Modifier.height(16.dp)) 364 | 365 | Text("No Waves (Regular Box)") 366 | Spacer(modifier = Modifier.height(8.dp)) 367 | Box( 368 | modifier = Modifier 369 | .size(180.dp) 370 | .padding(vertical = 8.dp) 371 | ) { 372 | WavyBox( 373 | spec = WavyBoxSpec( 374 | topWavy = false, 375 | rightWavy = false, 376 | bottomWavy = false, 377 | leftWavy = false, 378 | crestHeight = 6.dp, 379 | ), 380 | style = WavyBoxStyle.FilledWithColor( 381 | strokeWidth = 2.dp, 382 | strokeColor = Color.Gray, 383 | color = MaterialTheme.colorScheme.secondaryContainer, 384 | ), 385 | modifier = Modifier.fillMaxSize(), 386 | ) { 387 | Text( 388 | "Regular Box\n(No Waves)", 389 | textAlign = TextAlign.Center, 390 | color = MaterialTheme.colorScheme.onSecondaryContainer 391 | ) 392 | } 393 | } 394 | 395 | Spacer(modifier = Modifier.height(16.dp)) 396 | 397 | Text("Animated Crest Height") 398 | Spacer(modifier = Modifier.height(8.dp)) 399 | Box( 400 | modifier = Modifier 401 | .size(180.dp) 402 | .padding(vertical = 8.dp) 403 | ) { 404 | val infiniteTransition = rememberInfiniteTransition() 405 | val animatedCrestHeight by infiniteTransition.animateFloat( 406 | initialValue = 0f, 407 | targetValue = 15f, 408 | animationSpec = infiniteRepeatable( 409 | animation = tween(1000, easing = LinearEasing), 410 | repeatMode = RepeatMode.Reverse 411 | ) 412 | ) 413 | 414 | WavyBox( 415 | spec = WavyBoxSpec( 416 | topWavy = true, 417 | rightWavy = false, 418 | bottomWavy = true, 419 | leftWavy = false, 420 | crestHeight = animatedCrestHeight.dp, 421 | waveLength = 40.dp 422 | ), 423 | style = WavyBoxStyle.FilledWithColor( 424 | strokeWidth = 2.dp, 425 | strokeColor = MaterialTheme.colorScheme.primary, 426 | color = MaterialTheme.colorScheme.primaryContainer 427 | ), 428 | modifier = Modifier.fillMaxSize(), 429 | ) { 430 | Text( 431 | "Animated\nCrest Height", 432 | textAlign = TextAlign.Center, 433 | color = MaterialTheme.colorScheme.onPrimaryContainer 434 | ) 435 | } 436 | } 437 | } 438 | } 439 | } 440 | } 441 | 442 | @Composable 443 | private fun DemoSection( 444 | title: String, 445 | content: @Composable () -> Unit 446 | ) { 447 | Card( 448 | modifier = Modifier 449 | .fillMaxWidth() 450 | .padding(vertical = 8.dp) 451 | ) { 452 | Column( 453 | modifier = Modifier.padding(16.dp) 454 | ) { 455 | Text( 456 | text = title, 457 | style = MaterialTheme.typography.titleLarge, 458 | modifier = Modifier.padding(bottom = 16.dp) 459 | ) 460 | content() 461 | } 462 | } 463 | } 464 | 465 | @Preview(showBackground = true) 466 | @Composable 467 | fun WavesDemoScreenPreview() { 468 | Surface { 469 | WavesDemoScreen() 470 | } 471 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Useless UI 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |