├── .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://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://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 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/kenkeremath/uselessui/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build-logic/convention/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/build-logic/convention/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | group = "com.seankenkeremath.uselessui.convention"
6 |
7 | java {
8 | sourceCompatibility = JavaVersion.VERSION_17
9 | targetCompatibility = JavaVersion.VERSION_17
10 | }
11 |
12 | dependencies {
13 | compileOnly(libs.android.gradlePlugin)
14 | compileOnly(libs.kotlin.gradlePlugin)
15 | compileOnly(libs.vanniktech.maven.publish)
16 | }
17 |
18 | gradlePlugin {
19 | plugins {
20 | create("uselessUiLibMavenPublish") {
21 | id = "com.seankenkeremath.uselessui.convention.publish"
22 | implementationClass = "com.seankenkeremath.uselessui.convention.UselessUiLibMavenPublishConventionPlugin"
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/build-logic/convention/src/main/kotlin/com/seankenkeremath/uselessui/convention/MavenPublishConfigExtension.kt:
--------------------------------------------------------------------------------
1 | package com.seankenkeremath.uselessui.convention
2 |
3 | import org.gradle.api.model.ObjectFactory
4 | import org.gradle.api.provider.Property
5 | import javax.inject.Inject
6 |
7 | abstract class MavenPublishConfigExtension @Inject constructor(objects: ObjectFactory) {
8 | abstract val version: Property
9 | abstract val description: Property
10 | }
--------------------------------------------------------------------------------
/build-logic/convention/src/main/kotlin/com/seankenkeremath/uselessui/convention/UselessUiLibMavenPublishConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.seankenkeremath.uselessui.convention
2 |
3 | import com.vanniktech.maven.publish.MavenPublishBaseExtension
4 | import org.gradle.api.Plugin
5 | import org.gradle.api.Project
6 |
7 | class UselessUiLibMavenPublishConventionPlugin : Plugin {
8 |
9 | override fun apply(project: Project) {
10 | project.pluginManager.apply("com.vanniktech.maven.publish")
11 |
12 | val extension = project.extensions.create(
13 | "mavenPublishConfig",
14 | MavenPublishConfigExtension::class.java
15 | )
16 |
17 | project.afterEvaluate {
18 | project.extensions.configure("mavenPublishing") {
19 | coordinates(
20 | groupId = project.findProperty("GROUP") as String,
21 | artifactId = project.name,
22 | version = extension.version.orNull ?: "0.0.1"
23 | )
24 |
25 | pom {
26 | name.set(project.name)
27 | description.set(extension.description.orElse("No description provided"))
28 | }
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/build-logic/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | dependencyResolutionManagement {
4 | repositories {
5 | google()
6 | mavenCentral()
7 | }
8 |
9 | versionCatalogs {
10 | create("libs") {
11 | from(files("../gradle/libs.versions.toml"))
12 | }
13 | }
14 | }
15 |
16 | rootProject.name = "build-logic"
17 | include(":convention")
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application) apply false
3 | alias(libs.plugins.android.library) apply false
4 | alias(libs.plugins.kotlin.android) apply false
5 | alias(libs.plugins.kotlin.compose) apply false
6 | alias(libs.plugins.vanniktech.maven.publish) apply false
7 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 |
25 | # Publishing
26 | GROUP=io.github.seankenkeremath
27 | SONATYPE_HOST=CENTRAL_PORTAL
28 | RELEASE_SIGNING_ENABLED=true
29 |
30 | # Overridable POM information
31 | POM_NAME=Useless UI Components
32 | POM_DESCRIPTION=A collection of fun but useless UI components for Android
33 | POM_INCEPTION_YEAR=2024
34 | VERSION_NAME=0.0.1
35 |
36 | # Common POM information
37 | POM_URL=https://github.com/seankenkeremath/uselessui
38 |
39 | POM_LICENSE_NAME=MIT License
40 | POM_LICENSE_URL=https://opensource.org/licenses/MIT
41 | POM_LICENSE_DIST=repo
42 |
43 | POM_SCM_URL=https://github.com/seankenkeremath/uselessui
44 | POM_SCM_CONNECTION=scm:git:git://github.com/seankenkeremath/uselessui.git
45 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/seankenkeremath/uselessui.git
46 |
47 | POM_DEVELOPER_ID=seankenkeremath
48 | POM_DEVELOPER_NAME=Sean Kenkeremath
49 | POM_DEVELOPER_URL=https://github.com/seankenkeremath/
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.9.0"
3 | jtsCore = "1.18.2"
4 | kotlin = "2.0.21"
5 | vanniktechMavenPublish = "0.31.0-rc2"
6 | coreKtx = "1.15.0"
7 | junit = "4.13.2"
8 | junitVersion = "1.2.1"
9 | espressoCore = "3.6.1"
10 | lifecycleRuntimeKtx = "2.8.7"
11 | activityCompose = "1.10.1"
12 | composeBom = "2025.03.00"
13 | navigationCompose = "2.8.9"
14 |
15 | [libraries]
16 | android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
17 | kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
18 | vanniktech-maven-publish = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "vanniktechMavenPublish" }
19 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
20 | jts-core = { module = "org.locationtech.jts:jts-core", version.ref = "jtsCore" }
21 | junit = { group = "junit", name = "junit", version.ref = "junit" }
22 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
23 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
24 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
25 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
26 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
27 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
28 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
29 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
30 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
31 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
32 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
33 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
34 | androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
35 |
36 | [plugins]
37 | android-application = { id = "com.android.application", version.ref = "agp" }
38 | android-library = { id = "com.android.library", version.ref = "agp" }
39 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
40 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
41 | vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
42 |
43 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Mar 17 18:57:25 EDT 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/images/distortion_box_demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/images/distortion_box_demo.gif
--------------------------------------------------------------------------------
/images/shatterable_layout_demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/images/shatterable_layout_demo.gif
--------------------------------------------------------------------------------
/images/shatterpager_demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/images/shatterpager_demo.gif
--------------------------------------------------------------------------------
/images/waves_demo1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/images/waves_demo1.gif
--------------------------------------------------------------------------------
/images/waves_demo2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/images/waves_demo2.gif
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | includeBuild("build-logic")
14 | }
15 | dependencyResolutionManagement {
16 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17 | repositories {
18 | google()
19 | mavenCentral()
20 | }
21 | }
22 |
23 | rootProject.name = "Useless UI"
24 | include(":app")
25 | include(":shatterable-layout")
26 | include(":waves")
27 |
--------------------------------------------------------------------------------
/shatterable-layout/.gitignore:
--------------------------------------------------------------------------------
1 | build/
--------------------------------------------------------------------------------
/shatterable-layout/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | alias(libs.plugins.vanniktech.maven.publish)
6 | id("com.seankenkeremath.uselessui.convention.publish")
7 | }
8 |
9 | android {
10 | namespace = "com.kenkeremath.uselessui.shatter"
11 | compileSdk = 35
12 |
13 | defaultConfig {
14 | minSdk = 24
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = false
21 | proguardFiles(
22 | getDefaultProguardFile("proguard-android-optimize.txt"),
23 | "proguard-rules.pro"
24 | )
25 | }
26 | }
27 | compileOptions {
28 | sourceCompatibility = JavaVersion.VERSION_11
29 | targetCompatibility = JavaVersion.VERSION_11
30 | }
31 | kotlinOptions {
32 | jvmTarget = "11"
33 | }
34 | buildFeatures {
35 | compose = true
36 | }
37 | }
38 |
39 | dependencies {
40 | implementation(libs.androidx.core.ktx)
41 | implementation(libs.androidx.lifecycle.runtime.ktx)
42 | implementation(platform(libs.androidx.compose.bom))
43 | implementation(libs.androidx.ui)
44 | implementation(libs.androidx.ui.graphics)
45 | implementation(libs.androidx.ui.tooling.preview)
46 | implementation(libs.androidx.material3)
47 |
48 | implementation(libs.jts.core)
49 |
50 | testImplementation(libs.junit)
51 | androidTestImplementation(libs.androidx.junit)
52 | androidTestImplementation(libs.androidx.espresso.core)
53 | androidTestImplementation(platform(libs.androidx.compose.bom))
54 | androidTestImplementation(libs.androidx.ui.test.junit4)
55 | debugImplementation(libs.androidx.ui.tooling)
56 | debugImplementation(libs.androidx.ui.test.manifest)
57 | }
58 |
59 | mavenPublishConfig {
60 | description.set("A Compose UI component that can shatter into pieces")
61 | version.set("0.1.0")
62 | }
--------------------------------------------------------------------------------
/shatterable-layout/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
--------------------------------------------------------------------------------
/shatterable-layout/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/shatterable-layout/src/main/java/com/kenkeremath/uselessui/shatter/ShatterPager.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.shatter
2 |
3 | import androidx.compose.foundation.gestures.Orientation
4 | import androidx.compose.foundation.gestures.TargetedFlingBehavior
5 | import androidx.compose.foundation.gestures.snapping.SnapPosition
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.PaddingValues
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.pager.HorizontalPager
11 | import androidx.compose.foundation.pager.PageSize
12 | import androidx.compose.foundation.pager.PagerDefaults
13 | import androidx.compose.foundation.pager.PagerScope
14 | import androidx.compose.foundation.pager.PagerState
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
19 | import androidx.compose.ui.unit.Dp
20 | import androidx.compose.ui.unit.dp
21 | import kotlin.math.abs
22 | import kotlin.math.absoluteValue
23 |
24 | /**
25 | * A pager component that applies a shatter effect during page transitions with a provided PagerState.
26 | *
27 | * * This component uses the ShatterableLayout to create a shatter animation effect
28 | * * as the user swipes between pages. Each page has its own shatter effect, with
29 | * * offscreen pages starting shattered and becoming intact as they come into view.
30 | *
31 | * @param state The state object that controls the pager
32 | * @param modifier Modifier to be applied to the pager
33 | * @param shatterSpec Configuration for the shatter animation properties
34 | * @param captureMode Controls when the content bitmap is captured
35 | * @param showCenterPoints Whether to show debug points for the shatter centers * @param pageSpacing Spacing between pages
36 | * @param pageSpacing Spacing between pages
37 | * @param verticalAlignment The vertical alignment of the pages
38 | * @param contentPadding a padding around the whole content. This will add padding for the content after it has been clipped, which is not possible via modifier param. You can use it to add a padding before the first page or after the last one. Use pageSpacing to add spacing between the pages.
39 | * @param pageSize Use this to change how the pages will look like inside this pager.
40 | * @param beyondViewportPageCount Pages to compose and layout before and after the list of visible pages. Note: Be aware that using a large value for beyondViewportPageCount will cause a lot of pages to be composed, measured and placed which will defeat the purpose of using lazy loading. This should be used as an optimization to pre-load a couple of pages before and after the visible ones. This does not include the pages automatically composed and laid out by the pre-fetcher in the direction of the scroll during scroll events.
41 | * @param flingBehavior The fling behavior to use for the pager
42 | * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions is allowed. You can still scroll programmatically using PagerState. scroll even when it is disabled.
43 | * @param reverseLayout Whether the layout should be reversed when scrolling
44 | * @param key a stable and unique key representing the item. When you specify the key the scroll position will be maintained based on the key, which means if you add/ remove items before the current visible item the item with the given key will be kept as the first visible one. If null is passed the position in the list will represent the key.
45 | * @param contentKey for a given page, this determines when the shattered bitmap should be invalidated. If this key changes, it is assumed the content rendered in the bitmap did as well and we need to capture again. By default, we just look at the page index
46 | * @param pageNestedScrollConnection The nested scroll connection to be used for the pages
47 | * @param snapPosition The calculation of how this Pager will perform snapping of pages. Use this to provide different settling to different positions in the layout. This is used by Pager as a way to calculate PagerState.
48 | * @param pageContent The content to display for each page
49 | */
50 | @Composable
51 | fun ShatterPager(
52 | state: PagerState,
53 | modifier: Modifier = Modifier,
54 | shatterSpec: ShatterSpec = ShatterSpec(),
55 | captureMode: CaptureMode = CaptureMode.LAZY,
56 | showCenterPoints: Boolean = false,
57 | contentPadding: PaddingValues = PaddingValues(0.dp),
58 | pageSize: PageSize = PageSize.Fill,
59 | beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount,
60 | pageSpacing: Dp = 0.dp,
61 | verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
62 | flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state),
63 | userScrollEnabled: Boolean = true,
64 | reverseLayout: Boolean = false,
65 | key: ((index: Int) -> Any)? = null,
66 | contentKey: ((index: Int) -> Any)? = null,
67 | pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
68 | state,
69 | Orientation.Horizontal
70 | ),
71 | snapPosition: SnapPosition = SnapPosition.Start,
72 | pageContent: @Composable PagerScope.(page: Int) -> Unit
73 | ) {
74 | HorizontalPager(
75 | state = state,
76 | modifier = modifier,
77 | contentPadding = contentPadding,
78 | pageSize = pageSize,
79 | beyondViewportPageCount = beyondViewportPageCount,
80 | pageSpacing = pageSpacing,
81 | verticalAlignment = verticalAlignment,
82 | flingBehavior = flingBehavior,
83 | userScrollEnabled = userScrollEnabled,
84 | reverseLayout = reverseLayout,
85 | key = key,
86 | pageNestedScrollConnection = pageNestedScrollConnection,
87 | snapPosition = snapPosition
88 | ) { page ->
89 | val shatterProgress = state.getOffsetDistanceInPages(page).absoluteValue
90 | .coerceIn(0f, 1f)
91 |
92 | val progress =
93 | if (abs(shatterProgress) < .0001f) 0f else shatterProgress
94 |
95 | // Wrap each page in its own ShatterableLayout
96 | ShatterableLayout(
97 | progress = progress,
98 | captureMode = captureMode,
99 | shatterSpec = shatterSpec,
100 | contentKey = contentKey?.invoke(page) ?: page,
101 | showCenterPoints = showCenterPoints,
102 | modifier = Modifier
103 | .fillMaxSize()
104 | .padding(4.dp)
105 | ) {
106 | Box(modifier = Modifier.fillMaxSize()) {
107 | pageContent(page)
108 | }
109 | }
110 | }
111 | }
--------------------------------------------------------------------------------
/shatterable-layout/src/main/java/com/kenkeremath/uselessui/shatter/ShatterableLayout.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.shatter
2 |
3 | import android.view.View
4 | import androidx.compose.animation.core.Easing
5 | import androidx.compose.animation.core.FastOutSlowInEasing
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.size
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.LaunchedEffect
10 | import androidx.compose.runtime.Stable
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.runtime.mutableStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.runtime.setValue
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.geometry.Offset
17 | import androidx.compose.ui.graphics.ImageBitmap
18 | import androidx.compose.ui.graphics.asImageBitmap
19 | import androidx.compose.ui.layout.onGloballyPositioned
20 | import androidx.compose.ui.platform.ComposeView
21 | import androidx.compose.ui.platform.LocalDensity
22 | import androidx.compose.ui.unit.IntSize
23 | import androidx.compose.ui.viewinterop.AndroidView
24 | import androidx.core.graphics.createBitmap
25 |
26 | /**
27 | * A composable that can shatter its content when triggered.
28 | *
29 | * This layout captures a bitmap of its content and can render its content as a shattered image
30 | * based on the ShatterSpec parameters passed in.
31 | *
32 | * @param progress How far along (0..1f) the shatter effect should be rendered. 1f is completely shattered, 0f is intact
33 | * @param modifier Modifier to be applied to the layout
34 | * @param contentKey A key to invalidate the content bitmap when it changes
35 | * @param captureMode Controls when the content bitmap is captured (AUTO or LAZY)
36 | * @param shatterCenter The center point of the shatter effect, Offset.Unspecified for center
37 | * @param shatterSpec Configuration for the shatter animation properties
38 | * @param showCenterPoints Whether to show debug points for the shatter centers
39 | * @param content The content to be displayed and potentially shattered
40 | */
41 | @Composable
42 | fun ShatterableLayout(
43 | progress: Float,
44 | modifier: Modifier = Modifier,
45 | contentKey: Any? = null,
46 | captureMode: CaptureMode = CaptureMode.AUTO,
47 | showCenterPoints: Boolean = false,
48 | shatterCenter: Offset = Offset.Unspecified,
49 | shatterSpec: ShatterSpec = ShatterSpec(),
50 | content: @Composable () -> Unit
51 | ) {
52 | var size by remember { mutableStateOf(IntSize.Zero) }
53 | var contentBitmap by remember { mutableStateOf(null) }
54 | var needsRecapture by remember { mutableStateOf(false) }
55 |
56 | // Handle content key changes by marking the bitmap as invalid
57 | LaunchedEffect(contentKey) {
58 | if (contentBitmap != null) {
59 | needsRecapture = true
60 | }
61 | }
62 |
63 | // Handle size changes by marking the bitmap as invalid
64 | LaunchedEffect(size) {
65 | if (contentBitmap != null &&
66 | (contentBitmap!!.width != size.width ||
67 | contentBitmap!!.height != size.height)
68 | ) {
69 | needsRecapture = true
70 | }
71 | }
72 |
73 | Box(
74 | modifier = modifier
75 | .onGloballyPositioned { coordinates ->
76 | if (size != coordinates.size) {
77 | size = coordinates.size
78 | }
79 | }
80 | ) {
81 | // Capture the content bitmap if needed
82 | if ((captureMode != CaptureMode.LAZY || progress > 0f) && (contentBitmap == null || needsRecapture) && size.width > 0 && size.height > 0) {
83 | AndroidView(
84 | factory = { ctx ->
85 | ComposeView(ctx).apply {
86 | // Prevent this from being rendered for the first frame
87 | // if we are starting shattered
88 | visibility = if (progress < .1f) View.VISIBLE else View.INVISIBLE
89 | setContent {
90 | Box {
91 | content()
92 | }
93 | }
94 |
95 | // Capture the bitmap after layout
96 | post {
97 | if (width > 0 && height > 0) {
98 | val bitmap = createBitmap(width, height)
99 | val canvas = android.graphics.Canvas(bitmap)
100 | draw(canvas)
101 | contentBitmap = bitmap.asImageBitmap()
102 | needsRecapture = false
103 | }
104 | }
105 | }
106 | }
107 | )
108 | } else if (contentBitmap != null && progress > 0f) {
109 | // Show shattered version if we're not intact OR if we're still animating back to intact
110 | ShatteredImage(
111 | bitmap = contentBitmap!!,
112 | shatterCenter = shatterCenter,
113 | showCenterPoints = showCenterPoints,
114 | progress = progress,
115 | shatterSpec = shatterSpec,
116 | modifier = Modifier.size(
117 | size.width.pxToDp(), size.height.pxToDp()
118 | )
119 | )
120 | } else if (progress < 0.1f) {
121 | // Otherwise show the normal content
122 | content()
123 | }
124 | }
125 | }
126 |
127 | @Composable
128 | internal fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
129 |
130 | /**
131 | * Controls when the content bitmap is captured in ShatterableLayout.
132 | *
133 | * This determines the strategy for when to capture the bitmap of the content
134 | * that will be used for the shatter effect.
135 | */
136 | enum class CaptureMode {
137 | /**
138 | * Automatically recapture the bitmap when content key changes or size changes.
139 | * This ensures the shattered image always reflects the current content but may
140 | * use more resources.
141 | */
142 | AUTO,
143 |
144 | /**
145 | * Only capture the bitmap when transitioning to the shattered state.
146 | * The old bitmap is invalidated when content key or size changes, but a new
147 | * bitmap is only captured when needed. This is more efficient but may not
148 | * always reflect the latest content state.
149 | */
150 | LAZY,
151 | }
152 |
153 | /**
154 | * Configuration for the shatter animation effect.
155 | *
156 | * This class defines how the shatter animation should behave, including the number of shards,
157 | * speed, rotation, and transparency of the shattered pieces.
158 | *
159 | * Velocity determines how quickly the pieces move and therefore how far they go.
160 | * All target values represent what the values of those properties should be when
161 | * the shatter has completed.
162 | * Variation values are the range within which a property will randomly vary for
163 | * a given piece. This is important to make all pieces have some slight differences
164 | * in movement.
165 | *
166 | * @property shardCount Number of pieces the content will be broken into
167 | * @property velocity Base velocity of the shattered pieces
168 | * @property rotationXTarget Target X-axis rotation of pieces at the end of animation
169 | * @property rotationYTarget Target Y-axis rotation of pieces at the end of animation
170 | * @property rotationZTarget Target Z-axis rotation of pieces at the end of animation
171 | * @property velocityVariation Random variation in velocity between pieces
172 | * @property rotationXVariation Random variation in X-axis rotation between pieces
173 | * @property rotationYVariation Random variation in Y-axis rotation between pieces
174 | * @property rotationZVariation Random variation in Z-axis rotation between pieces
175 | * @property alphaTarget Target transparency of pieces at the end of animation (0 = transparent)
176 | */
177 | @Stable
178 | data class ShatterSpec(
179 | val shardCount: Int = 15,
180 | val easing: Easing = FastOutSlowInEasing,
181 | val velocity: Float = 300f,
182 | val rotationXTarget: Float = 30f,
183 | val rotationYTarget: Float = 30f,
184 | val rotationZTarget: Float = 30f,
185 | val velocityVariation: Float = 100f,
186 | val rotationXVariation: Float = 10f,
187 | val rotationYVariation: Float = 10f,
188 | val rotationZVariation: Float = 10f,
189 | val alphaTarget: Float = 0f,
190 | )
--------------------------------------------------------------------------------
/shatterable-layout/src/main/java/com/kenkeremath/uselessui/shatter/ShatteredImage.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.shatter
2 |
3 |
4 | import android.graphics.Bitmap
5 | import android.graphics.Color
6 | import android.graphics.Paint
7 | import android.graphics.PorterDuff
8 | import android.graphics.PorterDuffXfermode
9 | import android.graphics.RectF
10 | import androidx.compose.foundation.Image
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.material3.Surface
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.geometry.Offset
19 | import androidx.compose.ui.graphics.ImageBitmap
20 | import androidx.compose.ui.graphics.Path
21 | import androidx.compose.ui.graphics.TransformOrigin
22 | import androidx.compose.ui.graphics.asAndroidBitmap
23 | import androidx.compose.ui.graphics.asAndroidPath
24 | import androidx.compose.ui.graphics.asImageBitmap
25 | import androidx.compose.ui.graphics.graphicsLayer
26 | import androidx.compose.ui.platform.LocalDensity
27 | import androidx.compose.ui.tooling.preview.Preview
28 | import androidx.compose.ui.unit.dp
29 | import androidx.core.graphics.createBitmap
30 | import kotlin.math.sqrt
31 | import kotlin.random.Random
32 |
33 |
34 | @Composable
35 | private fun ShatteredPiece(
36 | shard: ShardData,
37 | originalBitmap: ImageBitmap,
38 | impactPoint: Offset,
39 | progress: Float,
40 | shatterSpec: ShatterSpec,
41 | showCenterPoint: Boolean = false,
42 | ) {
43 | if (shard.vertices.isEmpty()) return
44 |
45 | val randomVariations = remember(shard) {
46 | ShardRandomVariations(
47 | velocityVariation = Random.nextFloat() * shatterSpec.velocityVariation - shatterSpec.velocityVariation / 2,
48 | rotationXVariation = Random.nextFloat() * shatterSpec.rotationXVariation - shatterSpec.rotationXVariation / 2,
49 | rotationYVariation = Random.nextFloat() * shatterSpec.rotationYVariation - shatterSpec.rotationYVariation / 2,
50 | rotationZVariation = Random.nextFloat() * shatterSpec.rotationZVariation - shatterSpec.rotationZVariation / 2
51 | )
52 | }
53 |
54 | val direction = remember(impactPoint, shard.center) {
55 | computeOutwardDirection(impactPoint, shard.center)
56 | }
57 |
58 | val velocity = shatterSpec.velocity + randomVariations.velocityVariation
59 | val rotationXTarget = shatterSpec.rotationXTarget + randomVariations.rotationXVariation
60 | val rotationYTarget = shatterSpec.rotationYTarget + randomVariations.rotationYVariation
61 | val rotationZTarget = shatterSpec.rotationZTarget + randomVariations.rotationZVariation
62 | val alphaTarget = shatterSpec.alphaTarget
63 |
64 | val croppedBitmap = remember(originalBitmap, shard.path, shard.shardBoundingRect) {
65 | cropBitmapToFragmentBounds(originalBitmap, shard.path, shard.shardBoundingRect)
66 | }
67 |
68 | Box(
69 | modifier = Modifier
70 | .graphicsLayer {
71 | translationX = progress * direction.first * velocity
72 | translationY = progress * direction.second * velocity
73 | transformOrigin =
74 | TransformOrigin(shard.boundingCenterFractionX, shard.boundingCenterFractionY)
75 | rotationX = progress * rotationXTarget
76 | rotationY = progress * rotationYTarget
77 | rotationZ = progress * rotationZTarget
78 | alpha = 1f - progress * (1f - alphaTarget)
79 | cameraDistance = 16f * density
80 | }
81 | .size(
82 | with(LocalDensity.current) { shard.parentBoundingRect.width().toDp() },
83 | with(LocalDensity.current) { shard.parentBoundingRect.height().toDp() }
84 | )
85 | ) {
86 | Image(
87 | bitmap = croppedBitmap,
88 | contentDescription = null,
89 | modifier = Modifier
90 | .graphicsLayer {
91 | // Position the cropped bitmap at the correct location
92 | translationX = shard.shardBoundingRect.left
93 | translationY = shard.shardBoundingRect.top
94 | }
95 | )
96 |
97 | if (showCenterPoint) {
98 | androidx.compose.foundation.Canvas(
99 | modifier = Modifier.fillMaxSize()
100 | ) {
101 | drawCircle(
102 | color = androidx.compose.ui.graphics.Color.Red,
103 | radius = 8.dp.toPx(),
104 | center = shard.center
105 | )
106 |
107 | // Impact vector
108 | drawLine(
109 | color = androidx.compose.ui.graphics.Color.Yellow,
110 | start = shard.center,
111 | end = impactPoint,
112 | strokeWidth = 2.dp.toPx()
113 | )
114 | }
115 | }
116 | }
117 | }
118 |
119 | @Composable
120 | internal fun ShatteredImage(
121 | bitmap: ImageBitmap,
122 | progress: Float,
123 | modifier: Modifier = Modifier,
124 | shatterCenter: Offset = Offset.Unspecified,
125 | shatterSpec: ShatterSpec = ShatterSpec(),
126 | showCenterPoints: Boolean = false,
127 | ) {
128 | val impactPoint = remember(shatterCenter, bitmap) {
129 | if (shatterCenter == Offset.Unspecified) Offset(
130 | bitmap.width / 2f,
131 | bitmap.height / 2f
132 | ) else shatterCenter
133 | }
134 |
135 | val width = bitmap.width.toFloat()
136 | val height = bitmap.height.toFloat()
137 | val shardCount = shatterSpec.shardCount
138 | val shards = remember(bitmap, shardCount) {
139 | generateVoronoiShards(shardCount, width, height).map { path ->
140 | val fragmentBounds = RectF()
141 | path.path.asAndroidPath().computeBounds(fragmentBounds, true)
142 | ShardData(
143 | path = path.path,
144 | vertices = path.vertices,
145 | parentBoundingRect = RectF(0f, 0f, width, height),
146 | shardBoundingRect = fragmentBounds
147 | )
148 | }.filter { fragment ->
149 | // Filter out fragments with empty paths or zero area
150 | fragment.shardBoundingRect.width() > 0 &&
151 | fragment.shardBoundingRect.height() > 0 &&
152 | fragment.vertices.isNotEmpty()
153 | }
154 | }
155 |
156 | Box(
157 | modifier = modifier
158 | ) {
159 | shards.forEach { shard ->
160 | ShatteredPiece(
161 | shard = shard,
162 | originalBitmap = bitmap,
163 | impactPoint = impactPoint,
164 | progress = progress,
165 | shatterSpec = shatterSpec,
166 | showCenterPoint = showCenterPoints
167 | )
168 | }
169 | }
170 | }
171 |
172 |
173 | private data class ShardData(
174 | val path: Path,
175 | val vertices: List,
176 | val parentBoundingRect: RectF, // Bounding box of the original bitmap
177 | val shardBoundingRect: RectF // Bounding box of just this shard
178 | ) {
179 |
180 | val center: Offset
181 | get() {
182 | val centroidX = vertices.sumOf { it.x.toDouble() } / vertices.size
183 | val centroidY = vertices.sumOf { it.y.toDouble() } / vertices.size
184 | return Offset(centroidX.toFloat(), centroidY.toFloat())
185 | }
186 |
187 | // What fraction from 0f to 1f is the center of our shape in our bounding box in x and y
188 | // For instance, a normal rectangle would be (.5, .5), but within our bounding box the "center"
189 | // of a shard is a different location in the bounding box.
190 | val boundingCenterFractionX: Float
191 | get() = (center.x - parentBoundingRect.left) / parentBoundingRect.width()
192 |
193 | val boundingCenterFractionY: Float
194 | get() = (center.y - parentBoundingRect.top) / parentBoundingRect.height()
195 |
196 | }
197 |
198 | @Preview
199 | @Composable
200 | private fun ShatteredPiecePreview() {
201 | val bitmap = remember {
202 | createColoredBitmap(
203 | 300,
204 | 300,
205 | Color.argb(255, 0, 150, 255)
206 | ).asImageBitmap()
207 | }
208 |
209 | val path = Path().apply {
210 | moveTo(50f, 50f)
211 | lineTo(250f, 100f)
212 | lineTo(150f, 250f)
213 | close()
214 | }
215 |
216 | val bounds = RectF()
217 | path.asAndroidPath().computeBounds(bounds, true)
218 |
219 | val fragment = ShardData(
220 | path = path,
221 | vertices = listOf(
222 | Offset(50f, 50f),
223 | Offset(250f, 100f),
224 | Offset(150f, 250f)
225 | ),
226 | parentBoundingRect = RectF(0f, 0f, 300f, 300f),
227 | shardBoundingRect = bounds
228 | )
229 |
230 | Surface {
231 | Box(modifier = Modifier.size(300.dp)) {
232 | ShatteredPiece(
233 | shard = fragment,
234 | originalBitmap = bitmap,
235 | impactPoint = Offset(150f, 150f),
236 | progress = 0f,
237 | shatterSpec = ShatterSpec(),
238 | showCenterPoint = true
239 | )
240 | }
241 | }
242 | }
243 |
244 | @Preview
245 | @Composable
246 | private fun ShatteredImageComposablePreview() {
247 | val bitmap = remember {
248 | createColoredBitmap(
249 | 1000,
250 | 1000,
251 | Color.argb(255, 255, 0, 255)
252 | ).asImageBitmap()
253 | }
254 |
255 | Surface {
256 | Box {
257 | ShatteredImage(
258 | bitmap = bitmap,
259 | progress = .5f,
260 | shatterSpec = ShatterSpec(),
261 | showCenterPoints = true
262 | )
263 | }
264 | }
265 | }
266 |
267 | private fun cropBitmapToFragmentBounds(
268 | bitmap: ImageBitmap,
269 | path: Path,
270 | bounds: RectF
271 | ): ImageBitmap {
272 | // Create a bitmap that's only as large as the fragment's bounding box
273 | val left = bounds.left.toInt().coerceAtLeast(0)
274 | val top = bounds.top.toInt().coerceAtLeast(0)
275 | val width = bounds.width().toInt().coerceAtMost(bitmap.width - left)
276 | val height = bounds.height().toInt().coerceAtMost(bitmap.height - top)
277 |
278 | // Safety check - if dimensions are invalid, return a 1x1 transparent bitmap
279 | if (width <= 0 || height <= 0) {
280 | val fallbackBitmap = createBitmap(1, 1)
281 | fallbackBitmap.eraseColor(Color.TRANSPARENT)
282 | return fallbackBitmap.asImageBitmap()
283 | }
284 |
285 | // Create a bitmap of the exact size needed
286 | val resultBitmap = createBitmap(width, height)
287 | val canvas = android.graphics.Canvas(resultBitmap)
288 |
289 | // Translate the canvas so the path is positioned correctly
290 | canvas.translate(-left.toFloat(), -top.toFloat())
291 |
292 | val paint = Paint().apply {
293 | isAntiAlias = true
294 | }
295 | canvas.drawPath(path.asAndroidPath(), paint)
296 | paint.apply {
297 | xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
298 | }
299 |
300 | // Draw only the portion of the original bitmap that we need
301 | canvas.drawBitmap(bitmap.asAndroidBitmap(), 0f, 0f, paint)
302 |
303 | return resultBitmap.asImageBitmap()
304 | }
305 |
306 | private fun computeOutwardDirection(center: Offset, shardCenter: Offset): Pair {
307 | val dx = shardCenter.x - center.x
308 | val dy = shardCenter.y - center.y
309 | val distance = sqrt(dx * dx + dy * dy)
310 |
311 | return if (distance > 0f) {
312 | Pair(dx / distance, dy / distance) // Normalize vector
313 | } else {
314 | Pair(0f, 0f) // Prevent division by zero
315 | }
316 | }
317 |
318 | private fun createColoredBitmap(width: Int, height: Int, color: Int): Bitmap {
319 | val bitmap = createBitmap(width, height)
320 | val canvas = android.graphics.Canvas(bitmap)
321 | val paint = Paint().apply {
322 | this.color = color
323 | this.style = Paint.Style.FILL
324 | }
325 | canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
326 | paint.apply {
327 | this.color = Color.BLACK
328 | this.style = Paint.Style.FILL
329 | }
330 | canvas.drawRect(width / 2f, height / 2f, width.toFloat(), height.toFloat(), paint)
331 |
332 | return bitmap
333 | }
334 |
335 | private data class ShardRandomVariations(
336 | val velocityVariation: Float,
337 | val rotationXVariation: Float,
338 | val rotationYVariation: Float,
339 | val rotationZVariation: Float
340 | )
341 |
342 |
--------------------------------------------------------------------------------
/shatterable-layout/src/main/java/com/kenkeremath/uselessui/shatter/VoronoiTesellation.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.shatter
2 |
3 | import android.graphics.RectF
4 | import androidx.compose.ui.geometry.Offset
5 | import androidx.compose.ui.graphics.Path
6 | import androidx.compose.ui.graphics.asAndroidPath
7 | import androidx.compose.ui.graphics.toComposeRect
8 | import org.locationtech.jts.triangulate.VoronoiDiagramBuilder
9 | import org.locationtech.jts.geom.Coordinate
10 | import org.locationtech.jts.geom.GeometryCollection
11 | import org.locationtech.jts.geom.GeometryFactory
12 | import org.locationtech.jts.geom.Polygon
13 | import kotlin.random.Random
14 |
15 | internal class VoronoiTessellation(points: List>) {
16 | val cells: List
17 | init {
18 | val factory = GeometryFactory()
19 | val voronoiBuilder = VoronoiDiagramBuilder()
20 |
21 | val coords = points.map { Coordinate(it.first.toDouble(), it.second.toDouble()) }
22 |
23 | voronoiBuilder.setSites(coords)
24 | val diagram = voronoiBuilder.getDiagram(factory) as GeometryCollection
25 |
26 | cells = (0 until diagram.numGeometries).map { i ->
27 | val poly = diagram.getGeometryN(i) as Polygon
28 | val edges = poly.coordinates.map { Pair(it.x.toFloat(), it.y.toFloat()) }
29 | VoronoiCell(edges)
30 | }
31 | }
32 | }
33 | internal data class VoronoiCell(val edges: List>)
34 |
35 | internal fun generateVoronoiShards(count: Int, width: Float, height: Float): List {
36 | // Create a slightly larger area for the Voronoi diagram to ensure edge coverage
37 | val padding = 20f
38 |
39 | val points = mutableListOf>().apply {
40 | // Add random points inside the actual image area
41 | repeat(count) {
42 | add(Random.nextFloat() * width to Random.nextFloat() * height)
43 | }
44 |
45 | // Add points at corners
46 | add(0f to 0f)
47 | add(width to 0f)
48 | add(0f to height)
49 | add(width to height)
50 |
51 | // Add points at edge midpoints
52 | add(width/2f to 0f)
53 | add(width/2f to height)
54 | add(0f to height/2f)
55 | add(width to height/2f)
56 |
57 | // Add points outside the image to create proper cells at the edges
58 | // These will be clipped later but help create better edge fragments
59 | add(-padding to -padding)
60 | add(width + padding to -padding)
61 | add(-padding to height + padding)
62 | add(width + padding to height + padding)
63 |
64 | // Add more points along the outside edges
65 | add(-padding to height/2f)
66 | add(width + padding to height/2f)
67 | add(width/2f to -padding)
68 | add(width/2f to height + padding)
69 | }
70 |
71 | // Create the bounds for clipping
72 | val bounds = RectF(0f, 0f, width, height)
73 |
74 | // Generate the Voronoi diagram with the expanded points
75 | val voronoi = VoronoiTessellation(points)
76 |
77 | // Process the cells and clip them to the image bounds
78 | return voronoi.cells.mapNotNull { cell ->
79 | val vertices = cell.edges.map { Offset(it.first, it.second) }
80 |
81 | // Skip empty cells
82 | if (vertices.isEmpty()) return@mapNotNull null
83 |
84 | // Create the path
85 | val path = Path().apply {
86 | moveTo(vertices.first().x, vertices.first().y)
87 | vertices.forEach { lineTo(it.x, it.y) }
88 | close()
89 | }
90 |
91 | // Clip the path to the image bounds
92 | val clipPath = Path().apply {
93 | addRect(bounds.toComposeRect())
94 | }
95 |
96 | val clippedPath = Path().apply {
97 | op(path, clipPath, androidx.compose.ui.graphics.PathOperation.Intersect)
98 | }
99 |
100 | // Check if the clipped path is empty (completely outside the bounds)
101 | val pathBounds = RectF()
102 | clippedPath.asAndroidPath().computeBounds(pathBounds, true)
103 |
104 | // Skip cells that don't intersect with the image bounds
105 | if (pathBounds.width() <= 0 || pathBounds.height() <= 0) {
106 | return@mapNotNull null
107 | }
108 |
109 | // Return the clipped path with its vertices
110 | PathWithVertices(clippedPath, vertices)
111 | }
112 | }
113 |
114 | internal data class PathWithVertices(
115 | val path: Path,
116 | val vertices: List
117 | )
--------------------------------------------------------------------------------
/waves/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/waves/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | alias(libs.plugins.vanniktech.maven.publish)
6 | id("com.seankenkeremath.uselessui.convention.publish")
7 | }
8 |
9 | android {
10 | namespace = "com.kenkeremath.uselessui.waves"
11 | compileSdk = 35
12 |
13 | defaultConfig {
14 | minSdk = 24
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = false
21 | proguardFiles(
22 | getDefaultProguardFile("proguard-android-optimize.txt"),
23 | "proguard-rules.pro"
24 | )
25 | }
26 | }
27 | compileOptions {
28 | sourceCompatibility = JavaVersion.VERSION_11
29 | targetCompatibility = JavaVersion.VERSION_11
30 | }
31 | kotlinOptions {
32 | jvmTarget = "11"
33 | }
34 | buildFeatures {
35 | compose = true
36 | }
37 | }
38 |
39 | dependencies {
40 | implementation(libs.androidx.core.ktx)
41 | implementation(libs.androidx.lifecycle.runtime.ktx)
42 | implementation(platform(libs.androidx.compose.bom))
43 | implementation(libs.androidx.ui)
44 | implementation(libs.androidx.ui.graphics)
45 | implementation(libs.androidx.ui.tooling.preview)
46 | implementation(libs.androidx.material3)
47 |
48 | implementation(libs.jts.core)
49 |
50 | testImplementation(libs.junit)
51 | androidTestImplementation(libs.androidx.junit)
52 | androidTestImplementation(libs.androidx.espresso.core)
53 | androidTestImplementation(platform(libs.androidx.compose.bom))
54 | androidTestImplementation(libs.androidx.ui.test.junit4)
55 | debugImplementation(libs.androidx.ui.tooling)
56 | debugImplementation(libs.androidx.ui.test.manifest)
57 | }
58 |
59 | mavenPublishConfig {
60 | description.set("Components for creating wave animations")
61 | version.set("0.1.2")
62 | }
--------------------------------------------------------------------------------
/waves/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/seanKenkeremath/useless-ui/4a55489b2c41ae3d52aa298ab8ca2be23727a8ea/waves/consumer-rules.pro
--------------------------------------------------------------------------------
/waves/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
--------------------------------------------------------------------------------
/waves/src/androidTest/java/com/kenkeremath/uselessui/waves/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
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.waves.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/waves/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/waves/src/main/java/com/kenkeremath/uselessui/waves/DistortionBox.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
2 |
3 | import android.graphics.RenderEffect
4 | import android.graphics.RuntimeShader
5 | import android.os.Build
6 | import androidx.annotation.RequiresApi
7 | import androidx.compose.animation.core.AnimationSpec
8 | import androidx.compose.animation.core.animateFloatAsState
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.BoxScope
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.fillMaxSize
14 | import androidx.compose.foundation.layout.padding
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Surface
17 | import androidx.compose.material3.Text
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.LaunchedEffect
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.runtime.mutableFloatStateOf
22 | import androidx.compose.runtime.mutableStateOf
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.graphics.asComposeRenderEffect
28 | import androidx.compose.ui.graphics.graphicsLayer
29 | import androidx.compose.ui.layout.onSizeChanged
30 | import androidx.compose.ui.text.style.TextAlign
31 | import androidx.compose.ui.unit.dp
32 | import kotlinx.coroutines.delay
33 |
34 | /**
35 | * A composable that applies a wave distortion effect to its content using RuntimeShader.
36 | * This component requires Android 13 (API 33/Tiramisu) or higher to work.
37 | *
38 | * Adapted from https://medium.com/@kappdev/magic-wavy-button-in-jetpack-compose-unleashed-the-power-of-agsl-shaders-in-android-de502f882e35
39 | *
40 | * @param modifier Modifier to be applied to the component
41 | * @param playAnimation Whether to play the wave animation
42 | * @param content The content to be displayed inside the distorted box
43 | */
44 | @RequiresApi(Build.VERSION_CODES.TIRAMISU)
45 | @Composable
46 | fun DistortionBox(
47 | modifier: Modifier = Modifier,
48 | playAnimation: Boolean = true,
49 | content: @Composable BoxScope.() -> Unit = {},
50 | ) {
51 | var time by remember { mutableFloatStateOf(0f) }
52 |
53 | // Simulate frame updates for the animation
54 | LaunchedEffect(playAnimation) {
55 | while (playAnimation) {
56 | // Simulate 60 FPS
57 | delay(16)
58 | time += 0.016f
59 | }
60 | }
61 |
62 | DistortionBoxImpl(
63 | time = time,
64 | modifier = modifier,
65 | ) {
66 | content()
67 | }
68 | }
69 |
70 | /**
71 | * A composable that applies a wave distortion effect to its content using RuntimeShader.
72 | * This component requires Android 13 (API 33/Tiramisu) or higher to work. This is the stated-hoisted
73 | * version that allows the caller to control the flow of the animation by passing in the time value
74 | *
75 | * Adapted from https://medium.com/@kappdev/magic-wavy-button-in-jetpack-compose-unleashed-the-power-of-agsl-shaders-in-android-de502f882e35
76 | *
77 | * @param time Current animation time. This determines the progress of the distortion
78 | * @param modifier Modifier to be applied to the component
79 | * @param content The content to be displayed inside the distorted box
80 | */
81 | @RequiresApi(Build.VERSION_CODES.TIRAMISU)
82 | @Composable
83 | fun DistortionBoxImpl(
84 | time: Float,
85 | modifier: Modifier = Modifier,
86 | content: @Composable BoxScope.() -> Unit = {},
87 | ) {
88 | val shader = remember { RuntimeShader(distortionShader) }
89 |
90 | Box(
91 | modifier
92 | .onSizeChanged { size ->
93 | shader.setFloatUniform(
94 | "size",
95 | size.width.toFloat(),
96 | size.height.toFloat()
97 | )
98 | }
99 | .graphicsLayer {
100 | clip = true
101 | shader.setFloatUniform("time", time)
102 | renderEffect = RenderEffect
103 | .createRuntimeShaderEffect(shader, "composable")
104 | .asComposeRenderEffect()
105 | }
106 | ) {
107 | content()
108 | }
109 | }
110 |
111 | private const val distortionShader = """
112 | // Shader Input
113 | uniform float time; // Time
114 | uniform float2 size; // View size
115 | uniform shader composable; // Input texture
116 |
117 | // Constants
118 | const float speed = 10;
119 | const float waveAmplitude = 6;
120 | const float margin = 0.4;
121 | const float waveFrequency = 0.02;
122 |
123 | // Function to distort the coordinate based on wave deformations
124 | float2 distortCoord(in float2 originalCoord) {
125 | // Normalize the coordinates to [-1;1], with 0 at the center
126 | float2 uv = originalCoord / size * 2 - 1;
127 |
128 | // Calculate smoothstep values for the x and y coordinates
129 | float edgeX = 1 - smoothstep(0.0, margin, abs(uv.x)) * smoothstep(1.0 - margin, 1.0, abs(uv.x));
130 | float edgeY = 1 - smoothstep(0.0, margin, abs(uv.y)) * smoothstep(1.0 - margin, 1.0, abs(uv.y));
131 |
132 | // Combine the smoothstep values to create a smooth margin
133 | float edge = min(edgeX, edgeY);
134 |
135 | // Calculate the wave distortion offset based on the length of the distorted coordinate
136 | // switch the sign of +- time * speed to change direction
137 | float waveOffset = sin(length(originalCoord) * waveFrequency - time * speed);
138 |
139 | // Apply the wave distortion to the fragment coordinate
140 | return originalCoord + (waveOffset * waveAmplitude * edge);
141 | }
142 |
143 | float4 main(in float2 fragCoord) {
144 | float2 distortedCoord = distortCoord(fragCoord);
145 | return composable.eval(distortedCoord);
146 | }
147 | """
--------------------------------------------------------------------------------
/waves/src/main/java/com/kenkeremath/uselessui/waves/WaveUtils.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
2 |
3 | import androidx.compose.ui.geometry.Offset
4 | import androidx.compose.ui.graphics.Matrix
5 | import androidx.compose.ui.graphics.Path
6 | import androidx.compose.ui.unit.Dp
7 | import androidx.compose.ui.unit.Density
8 | import kotlin.math.atan2
9 | import kotlin.math.min
10 | import kotlin.math.roundToInt
11 | import kotlin.math.sin
12 | import kotlin.math.sqrt
13 |
14 | /**
15 | * Creates a wavy path between two points and adds it to an existing path.
16 | *
17 | * This function generates a sine wave path between two arbitrary points in 2D space.
18 | * The wave will be properly oriented along the line connecting the two points.
19 | * If an existing path is provided, the wavy segment will be added to it; otherwise,
20 | * a new path will be created.
21 | *
22 | * @param animationProgress Progress of the wave animation (0f to 1f). Controls the phase shift
23 | * of the sine wave, creating the animation effect when varied over time.
24 | * @param crestHeightPx Height of the wave crests in pixels. Controls the amplitude of the sine wave.
25 | * @param waveLengthPx Length of each complete wave cycle in pixels. Controls the frequency of the sine wave.
26 | * @param startPoint Starting point of the wavy path segment.
27 | * @param endPoint Ending point of the wavy path segment.
28 | * @param existingPath Optional existing path to add the wavy segment to. If null, a new path will be created.
29 | * @return The path with the wavy segment added. If existingPath was null, returns a new path.
30 | */
31 | fun wavyPathSegment(
32 | animationProgress: Float,
33 | crestHeightPx: Float,
34 | waveLengthPx: Float,
35 | startPoint: Offset,
36 | endPoint: Offset,
37 | existingPath: Path? = null
38 | ): Path {
39 | val path = existingPath ?: Path()
40 |
41 | // Calculate the distance between points
42 | val dx = endPoint.x - startPoint.x
43 | val dy = endPoint.y - startPoint.y
44 | val distance = sqrt(dx * dx + dy * dy)
45 |
46 | // Calculate the angle between points
47 | val angle = atan2(dy, dx)
48 |
49 | // Calculate wave parameters
50 | val stretch = 2f * PI / waveLengthPx
51 | val xShift = animationProgress * 2f * PI
52 |
53 | // Determine number of segments for smooth curve
54 | val segmentLength = waveLengthPx / 10f
55 | val numSegments = (distance / segmentLength).roundToInt().coerceAtLeast(1)
56 |
57 | // Create a matrix for transformations
58 | val matrix = Matrix()
59 | matrix.translate(startPoint.x, startPoint.y)
60 | matrix.rotateZ(angle * (180f / PI))
61 |
62 | // Create points for the wavy path
63 | val points = mutableListOf()
64 |
65 | // Add the start point
66 | points.add(Offset(0f, 0f))
67 |
68 | // Add intermediate points
69 | for (i in 1..numSegments) {
70 | val x = min(i * segmentLength, distance)
71 | val y = getWaveYAtPoint(x, crestHeightPx, waveLengthPx, animationProgress)
72 | points.add(Offset(x, y))
73 | }
74 |
75 | // Ensure we reach the exact end point
76 | if (numSegments * segmentLength < distance) {
77 | val x = distance
78 | val y = getWaveYAtPoint(x, crestHeightPx, waveLengthPx, animationProgress)
79 | points.add(Offset(x, y))
80 | }
81 |
82 | // Transform all points
83 | val transformedPoints = points.map { point ->
84 | matrix.map(point)
85 | }
86 |
87 | // Add points to the path
88 | if (existingPath == null) {
89 | path.moveTo(transformedPoints[0].x, transformedPoints[0].y)
90 | }
91 |
92 | for (i in 1 until transformedPoints.size) {
93 | path.lineTo(transformedPoints[i].x, transformedPoints[i].y)
94 | }
95 |
96 | return path
97 | }
98 |
99 | internal fun getWaveYAtPoint(
100 | x: Float,
101 | crestHeightPx: Float,
102 | waveLengthPx: Float,
103 | progress: Float
104 | ): Float {
105 | val stretch = 2f * PI / waveLengthPx
106 | val xShift = progress * 2f * PI
107 | return crestHeightPx * sin(stretch * x - xShift)
108 | }
109 |
110 | private const val PI = Math.PI.toFloat()
111 |
112 | /**
113 | * Convenience function that accepts Dp values and converts them to pixels before creating the wavy path.
114 | */
115 | fun wavyPathSegment(
116 | animationProgress: Float,
117 | crestHeight: Dp,
118 | waveLength: Dp,
119 | startPoint: Offset,
120 | endPoint: Offset,
121 | existingPath: Path? = null,
122 | density: Density
123 | ): Path {
124 | return wavyPathSegment(
125 | animationProgress = animationProgress,
126 | crestHeightPx = with(density) { crestHeight.toPx() },
127 | waveLengthPx = with(density) { waveLength.toPx() },
128 | startPoint = startPoint,
129 | endPoint = endPoint,
130 | existingPath = existingPath
131 | )
132 | }
--------------------------------------------------------------------------------
/waves/src/main/java/com/kenkeremath/uselessui/waves/WavyBox.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.RepeatMode
5 | import androidx.compose.animation.core.animateFloat
6 | import androidx.compose.animation.core.infiniteRepeatable
7 | import androidx.compose.animation.core.rememberInfiniteTransition
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.Canvas
10 | import androidx.compose.foundation.border
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.padding
14 | import androidx.compose.foundation.layout.size
15 | import androidx.compose.material3.Surface
16 | import androidx.compose.material3.Text
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.Immutable
19 | import androidx.compose.runtime.getValue
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.geometry.Offset
23 | import androidx.compose.ui.graphics.Brush
24 | import androidx.compose.ui.graphics.Color
25 | import androidx.compose.ui.graphics.Path
26 | import androidx.compose.ui.graphics.drawscope.Stroke
27 | import androidx.compose.ui.platform.LocalDensity
28 | import androidx.compose.ui.tooling.preview.Preview
29 | import androidx.compose.ui.unit.Density
30 | import androidx.compose.ui.unit.Dp
31 | import androidx.compose.ui.unit.dp
32 | import kotlin.math.max
33 |
34 | /**
35 | * A composable that draws a wavy box with manual control over the animation progress.
36 | * This state-hoisted version allows for more control over the animation.
37 | *
38 | * @param progress Animation progress value between 0f and 1f
39 | * @param spec Configuration for which sides of the box should be wavy
40 | * @param style Style configuration for the box (filled, outlined, etc.)
41 | * @param modifier Modifier to be applied to the component
42 | * @param content Optional composable content to be displayed inside the box
43 | */
44 | @Composable
45 | fun WavyBox(
46 | progress: Float,
47 | spec: WavyBoxSpec,
48 | style: WavyBoxStyle,
49 | modifier: Modifier = Modifier,
50 | content: @Composable () -> Unit = {}
51 | ) {
52 | val density = LocalDensity.current
53 |
54 | Box(modifier = modifier) {
55 | Canvas(
56 | modifier = Modifier.fillMaxSize()
57 | ) {
58 | val path = createWavyBoxPath(
59 | size = size,
60 | spec = spec,
61 | progress = progress,
62 | crestHeight = spec.crestHeight,
63 | waveLength = spec.waveLength,
64 | density = density
65 | )
66 |
67 | if (style is WavyBoxStyle.FilledWithBrush) {
68 | drawPath(
69 | path = path,
70 | brush = style.brush
71 | )
72 | } else if (style is WavyBoxStyle.FilledWithColor) {
73 | drawPath(
74 | path = path,
75 | color = style.color
76 | )
77 | }
78 |
79 | // Draw stroke
80 | drawPath(
81 | path = path,
82 | color = style.strokeColor,
83 | style = Stroke(width = style.strokeWidth.toPx())
84 | )
85 | }
86 |
87 | val additionalPadding = spec.crestHeight + style.strokeWidth
88 |
89 | Box(
90 | modifier = Modifier
91 | .fillMaxSize()
92 | .padding(
93 | top = if (spec.topWavy) additionalPadding else style.strokeWidth,
94 | bottom = if (spec.bottomWavy) additionalPadding else style.strokeWidth,
95 | start = if (spec.leftWavy) additionalPadding else style.strokeWidth,
96 | end = if (spec.rightWavy) additionalPadding else style.strokeWidth,
97 | ),
98 | contentAlignment = Alignment.Center
99 | ) {
100 | content()
101 | }
102 | }
103 | }
104 |
105 | /**
106 | * A composable that draws an animated wavy box.
107 | *
108 | * @param spec Configuration for which sides of the box should be wavy
109 | * @param style Style configuration for the box (filled, outlined, etc.)
110 | * @param modifier Modifier to be applied to the component
111 | * @param animationDurationMillis Duration of one complete wave animation cycle in milliseconds
112 | * @param content Optional composable content to be displayed inside the box
113 | */
114 | @Composable
115 | fun WavyBox(
116 | spec: WavyBoxSpec,
117 | style: WavyBoxStyle,
118 | modifier: Modifier = Modifier,
119 | animationDurationMillis: Int = 1000,
120 | content: @Composable () -> Unit = {}
121 | ) {
122 | val infiniteTransition = rememberInfiniteTransition()
123 | val wave by infiniteTransition.animateFloat(
124 | initialValue = 0f,
125 | targetValue = 1f,
126 | animationSpec = infiniteRepeatable(
127 | animation = tween(animationDurationMillis, easing = LinearEasing),
128 | repeatMode = RepeatMode.Restart
129 | )
130 | )
131 |
132 | WavyBox(
133 | progress = wave,
134 | spec = spec,
135 | style = style,
136 | modifier = modifier,
137 | content = content
138 | )
139 | }
140 |
141 | /**
142 | * Creates a path for a wavy box using the addWavyPathSegment utility
143 | */
144 | private fun createWavyBoxPath(
145 | size: androidx.compose.ui.geometry.Size,
146 | spec: WavyBoxSpec,
147 | progress: Float,
148 | crestHeight: Dp,
149 | waveLength: Dp,
150 | density: Density
151 | ): Path {
152 | // Convert crest height + wave length to pixels for offset calculations
153 | val crestHeightPx = with(density) { crestHeight.toPx() }
154 | val waveLengthPx = with(density) { waveLength.toPx() }
155 |
156 | // If centerAlongBounds is false, offset the path by crestHeight
157 | // so the top of the crest touches the bounds
158 | val topYOffset = if (spec.topWavy && !spec.centerAlongBounds) crestHeightPx else 0f
159 | val bottomYOffset = if (spec.bottomWavy && !spec.centerAlongBounds) crestHeightPx else 0f
160 | val leftXOffset = if (spec.leftWavy && !spec.centerAlongBounds) crestHeightPx else 0f
161 | val rightXOffset = if (spec.rightWavy && !spec.centerAlongBounds) crestHeightPx else 0f
162 |
163 | // Pre-calculate corner points
164 | val topLeftCorner = Offset(leftXOffset, topYOffset)
165 | val topRightCorner = Offset(size.width - rightXOffset, topYOffset)
166 | val bottomRightCorner = Offset(size.width - rightXOffset, size.height - bottomYOffset)
167 | val bottomLeftCorner = Offset(leftXOffset, size.height - bottomYOffset)
168 |
169 |
170 | // Since we are starting at the top left corner, we need to make sure the final point
171 | // on the left edge will meet back up at the correct position
172 | val leftEdgeWaveOffset = if (!spec.leftWavy) 0f else
173 | getWaveYAtPoint(
174 | x = topLeftCorner.y - bottomLeftCorner.y,
175 | progress = progress,
176 | crestHeightPx = crestHeightPx,
177 | waveLengthPx = waveLengthPx,
178 | )
179 | val adjustedTopLeft = topLeftCorner.copy(
180 | x = max(
181 | topLeftCorner.x + leftEdgeWaveOffset,
182 | topLeftCorner.x
183 | )
184 | )
185 | // Top edge (left to right)
186 | val path = if (spec.topWavy) {
187 | wavyPathSegment(
188 | animationProgress = progress,
189 | crestHeight = crestHeight,
190 | waveLength = waveLength,
191 | startPoint = adjustedTopLeft,
192 | endPoint = topRightCorner,
193 | density = density
194 | )
195 | } else {
196 | Path().apply {
197 | moveTo(adjustedTopLeft.x, adjustedTopLeft.y)
198 | lineTo(topRightCorner.x, topRightCorner.y)
199 | }
200 | }
201 |
202 | // Right edge (top to bottom)
203 | if (spec.rightWavy) {
204 | wavyPathSegment(
205 | existingPath = path,
206 | animationProgress = progress,
207 | crestHeight = crestHeight,
208 | waveLength = waveLength,
209 | startPoint = topRightCorner,
210 | endPoint = bottomRightCorner,
211 | density = density
212 | )
213 | } else {
214 | path.lineTo(bottomRightCorner.x, bottomRightCorner.y)
215 | }
216 |
217 | // Bottom edge (right to left)
218 | if (spec.bottomWavy) {
219 | wavyPathSegment(
220 | existingPath = path,
221 | animationProgress = progress,
222 | crestHeight = crestHeight,
223 | waveLength = waveLength,
224 | startPoint = bottomRightCorner,
225 | endPoint = bottomLeftCorner,
226 | density = density
227 | )
228 | } else {
229 | path.lineTo(bottomLeftCorner.x, bottomLeftCorner.y)
230 | }
231 |
232 | // Left edge (bottom to top)
233 | if (spec.leftWavy) {
234 | wavyPathSegment(
235 | existingPath = path,
236 | animationProgress = progress,
237 | crestHeight = crestHeight,
238 | waveLength = waveLength,
239 | startPoint = bottomLeftCorner,
240 | endPoint = topLeftCorner,
241 | density = density
242 | )
243 | } else {
244 | path.lineTo(topLeftCorner.x, topLeftCorner.y)
245 | }
246 |
247 | path.close()
248 | return path
249 | }
250 |
251 | @Immutable
252 | sealed class WavyBoxStyle {
253 | abstract val strokeWidth: Dp
254 | abstract val strokeColor: Color
255 |
256 | @Immutable
257 | data class FilledWithBrush(
258 | val brush: Brush,
259 | override val strokeWidth: Dp,
260 | override val strokeColor: Color
261 | ) : WavyBoxStyle()
262 |
263 | @Immutable
264 | data class FilledWithColor(
265 | val color: Color,
266 | override val strokeWidth: Dp,
267 | override val strokeColor: Color
268 | ) : WavyBoxStyle()
269 |
270 | @Immutable
271 | data class Outlined(
272 | override val strokeWidth: Dp,
273 | override val strokeColor: Color
274 | ) : WavyBoxStyle()
275 | }
276 |
277 | @Preview
278 | @Composable
279 | fun WavyBoxOutlinedPreview() {
280 | Surface(
281 | modifier = Modifier
282 | .size(200.dp),
283 | color = Color.White,
284 | ) {
285 | WavyBox(
286 | spec = WavyBoxSpec(
287 | topWavy = true,
288 | rightWavy = false,
289 | bottomWavy = true,
290 | leftWavy = false,
291 | crestHeight = 6.dp
292 | ),
293 | style = WavyBoxStyle.Outlined(
294 | strokeColor = Color.Black,
295 | strokeWidth = 2.dp
296 | ),
297 | modifier = Modifier
298 | .size(180.dp)
299 | .padding(16.dp),
300 | ) {
301 | Text("Wavy Box")
302 | }
303 | }
304 | }
305 |
306 | @Preview
307 | @Composable
308 | fun WavyBoxFilledPreview() {
309 | Surface(
310 | modifier = Modifier
311 | .size(200.dp),
312 | color = Color.White,
313 | ) {
314 | WavyBox(
315 | spec = WavyBoxSpec(
316 | topWavy = true,
317 | rightWavy = false,
318 | bottomWavy = true,
319 | leftWavy = false,
320 | crestHeight = 6.dp,
321 | ),
322 | style = WavyBoxStyle.FilledWithColor(
323 | color = Color.Cyan,
324 | strokeWidth = 0.dp,
325 | strokeColor = Color.Transparent
326 | ),
327 | modifier = Modifier
328 | .size(180.dp)
329 | .padding(16.dp),
330 | ) {
331 | Text("Filled Wavy Box")
332 | }
333 | }
334 | }
335 |
336 | @Preview
337 | @Composable
338 | fun WavyBoxCornerFilledPreview() {
339 | Surface(
340 | modifier = Modifier
341 | .size(200.dp),
342 | color = Color.White,
343 | ) {
344 | WavyBox(
345 | spec = WavyBoxSpec(
346 | topWavy = true,
347 | rightWavy = true,
348 | bottomWavy = true,
349 | leftWavy = false,
350 | crestHeight = 6.dp,
351 | ),
352 | style = WavyBoxStyle.FilledWithColor(
353 | color = Color.Cyan,
354 | strokeWidth = 0.dp,
355 | strokeColor = Color.Transparent
356 | ),
357 | modifier = Modifier
358 | .size(180.dp)
359 | .padding(16.dp),
360 | ) {
361 | Text("Filled Wavy Box")
362 | }
363 | }
364 | }
365 |
366 | @Preview
367 | @Composable
368 | fun WavyBoxAllWavesNotCenteredPreview() {
369 | Surface(
370 | modifier = Modifier
371 | .size(200.dp),
372 | color = Color.White,
373 | ) {
374 | WavyBox(
375 | spec = WavyBoxSpec(
376 | topWavy = true,
377 | rightWavy = true,
378 | bottomWavy = true,
379 | leftWavy = true,
380 | crestHeight = 6.dp,
381 | ),
382 | style = WavyBoxStyle.FilledWithColor(
383 | color = Color.Cyan,
384 | strokeWidth = 0.dp,
385 | strokeColor = Color.Transparent
386 | ),
387 | modifier = Modifier
388 | .size(180.dp)
389 | .padding(16.dp),
390 | ) {
391 | Text("Wavy Box")
392 | }
393 | Box(
394 | modifier = Modifier
395 | .size(200.dp)
396 | .padding(16.dp)
397 | .border(2.dp, Color.Black)
398 | )
399 | }
400 | }
401 |
402 | @Preview
403 | @Composable
404 | fun WavyBoxAllWavesCenteredPreview() {
405 | Surface(
406 | modifier = Modifier
407 | .size(200.dp),
408 | color = Color.White,
409 | ) {
410 | WavyBox(
411 | spec = WavyBoxSpec(
412 | topWavy = true,
413 | rightWavy = true,
414 | bottomWavy = true,
415 | leftWavy = true,
416 | crestHeight = 6.dp,
417 | centerAlongBounds = true
418 | ),
419 | style = WavyBoxStyle.FilledWithColor(
420 | color = Color.Cyan,
421 | strokeWidth = 0.dp,
422 | strokeColor = Color.Transparent
423 | ),
424 | modifier = Modifier
425 | .size(180.dp)
426 | .padding(16.dp),
427 | ) {
428 | Text("Wavy Box")
429 | }
430 | Box(
431 | modifier = Modifier
432 | .size(200.dp)
433 | .padding(16.dp)
434 | .border(2.dp, Color.Black)
435 | )
436 | }
437 | }
438 |
439 | @Preview
440 | @Composable
441 | fun WavyBoxNoWavesPreview() {
442 | Surface(
443 | modifier = Modifier
444 | .size(200.dp),
445 | color = Color.White,
446 | ) {
447 | WavyBox(
448 | spec = WavyBoxSpec(
449 | topWavy = false,
450 | rightWavy = false,
451 | bottomWavy = false,
452 | leftWavy = false,
453 | crestHeight = 6.dp,
454 | ),
455 | style = WavyBoxStyle.FilledWithColor(
456 | color = Color.Cyan,
457 | strokeWidth = 0.dp,
458 | strokeColor = Color.Transparent
459 | ),
460 | modifier = Modifier
461 | .size(180.dp)
462 | .padding(16.dp),
463 | ) {
464 | Text("Wavy Box")
465 | }
466 | Box(
467 | modifier = Modifier
468 | .size(200.dp)
469 | .padding(16.dp)
470 | .border(2.dp, Color.Black)
471 | )
472 | }
473 | }
474 |
475 | /**
476 | * Configuration specification for a WavyBox component.
477 | *
478 | * This class defines which sides of the box should have a wavy appearance and
479 | * controls the visual properties of the waves.
480 | *
481 | * @property topWavy When true, the top edge of the box will have a wavy appearance
482 | * @property rightWavy When true, the right edge of the box will have a wavy appearance
483 | * @property bottomWavy When true, the bottom edge of the box will have a wavy appearance
484 | * @property leftWavy When true, the left edge of the box will have a wavy appearance
485 | * @property crestHeight The height of each wave crest in dp. Controls the amplitude of the waves.
486 | * @property waveLength The length of each complete wave cycle in dp. Controls the frequency of the waves.
487 | * @property centerAlongBounds When true, the waves are centered along the bounds (half of the way will be outside the bounds).
488 | * When false, the top of the waves will be aligned with the bounds of the view.
489 | */
490 | @Immutable
491 | data class WavyBoxSpec(
492 | val topWavy: Boolean,
493 | val rightWavy: Boolean,
494 | val bottomWavy: Boolean,
495 | val leftWavy: Boolean,
496 | val crestHeight: Dp = 4.dp,
497 | val waveLength: Dp = 80.dp,
498 | val centerAlongBounds: Boolean = false
499 | )
--------------------------------------------------------------------------------
/waves/src/main/java/com/kenkeremath/uselessui/waves/WavyLine.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.RepeatMode
5 | import androidx.compose.animation.core.animateFloat
6 | import androidx.compose.animation.core.infiniteRepeatable
7 | import androidx.compose.animation.core.rememberInfiniteTransition
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.Canvas
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.material3.Surface
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.geometry.Offset
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.graphics.drawscope.Stroke
20 | import androidx.compose.ui.platform.LocalDensity
21 | import androidx.compose.ui.tooling.preview.Preview
22 | import androidx.compose.ui.unit.Dp
23 | import androidx.compose.ui.unit.dp
24 |
25 | /**
26 | * A composable that draws a wavy line with manual control over the animation progress.
27 | * This state-hoisted version allows for more control over the animation.
28 | *
29 | * @param progress Animation progress value between 0f and 1f
30 | * @param modifier Modifier to be applied to the component
31 | * @param crestHeight The height of each wave crest in dp
32 | * @param waveLength The length of each complete wave in dp
33 | * @param color The color of the wavy line
34 | * @param strokeWidth The width of the line stroke in dp
35 | * @param centerWave When true, the wave is centered vertically in the available space.
36 | * When false, the wave is positioned at the top with crestHeight as offset
37 | */
38 | @Composable
39 | fun WavyLine(
40 | progress: Float,
41 | modifier: Modifier = Modifier,
42 | crestHeight: Dp = 4.dp,
43 | waveLength: Dp = 80.dp,
44 | color: Color = Color.Blue,
45 | strokeWidth: Dp = 2.dp,
46 | centerWave: Boolean = false,
47 | ) {
48 | val density = LocalDensity.current
49 |
50 | Canvas(
51 | modifier = modifier
52 | ) {
53 | val startPoint = Offset(0f, if (centerWave) size.height / 2 else crestHeight.toPx())
54 | val endPoint = Offset(size.width, if (centerWave) size.height / 2 else crestHeight.toPx())
55 |
56 | // Convert Dp to pixels
57 | val crestHeightPx = with(density) { crestHeight.toPx() }
58 | val waveLengthPx = with(density) { waveLength.toPx() }
59 |
60 | val path = wavyPathSegment(
61 | existingPath = null,
62 | animationProgress = progress,
63 | crestHeightPx = crestHeightPx,
64 | waveLengthPx = waveLengthPx,
65 | startPoint = startPoint,
66 | endPoint = endPoint,
67 | )
68 |
69 | drawPath(
70 | path = path,
71 | color = color,
72 | style = Stroke(width = strokeWidth.toPx())
73 | )
74 | }
75 | }
76 |
77 | /**
78 | * A composable that draws an animated wavy line.
79 | *
80 | * @param modifier Modifier to be applied to the component
81 | * @param crestHeight The height of each wave crest in dp
82 | * @param waveLength The length of each complete wave in dp
83 | * @param color The color of the wavy line
84 | * @param strokeWidth The width of the line stroke in dp
85 | * @param centerWave When true, the wave is centered vertically in the available space.
86 | * When false, the wave is positioned at the top with crestHeight as offset
87 | * @param animationDurationMillis Duration of one complete wave animation cycle in milliseconds
88 | */
89 | @Composable
90 | fun WavyLine(
91 | modifier: Modifier = Modifier,
92 | crestHeight: Dp = 4.dp,
93 | waveLength: Dp = 80.dp,
94 | color: Color = Color.Blue,
95 | strokeWidth: Dp = 2.dp,
96 | centerWave: Boolean = false,
97 | animationDurationMillis: Int = 1000
98 | ) {
99 | val infiniteTransition = rememberInfiniteTransition()
100 | val wave by infiniteTransition.animateFloat(
101 | initialValue = 0f,
102 | targetValue = 1f,
103 | animationSpec = infiniteRepeatable(
104 | animation = tween(animationDurationMillis, easing = LinearEasing),
105 | repeatMode = RepeatMode.Restart
106 | )
107 | )
108 |
109 | WavyLine(
110 | progress = wave,
111 | modifier = modifier,
112 | crestHeight = crestHeight,
113 | waveLength = waveLength,
114 | color = color,
115 | strokeWidth = strokeWidth,
116 | centerWave = centerWave
117 | )
118 | }
119 |
120 | @Preview
121 | @Composable
122 | fun WavyLinePreview() {
123 | Surface(
124 | modifier = Modifier
125 | .height(100.dp)
126 | .fillMaxWidth(),
127 | color = Color.White,
128 | ) {
129 | WavyLine(
130 | modifier = Modifier
131 | .fillMaxWidth()
132 | .height(50.dp)
133 | .padding(vertical = 20.dp),
134 | color = Color.Blue
135 | )
136 | }
137 | }
138 |
139 | @Preview
140 | @Composable
141 | fun WavyLineCenteredPreview() {
142 | Surface(
143 | modifier = Modifier
144 | .height(100.dp)
145 | .fillMaxWidth(),
146 | color = Color.White,
147 | ) {
148 | WavyLine(
149 | modifier = Modifier
150 | .fillMaxWidth()
151 | .height(50.dp),
152 | centerWave = true,
153 | color = Color.Red,
154 | crestHeight = 10.dp
155 | )
156 | }
157 | }
158 |
159 | @Preview
160 | @Composable
161 | fun WavyLineDiagonalPreview() {
162 | val density = LocalDensity.current
163 | Surface(
164 | modifier = Modifier
165 | .height(100.dp)
166 | .fillMaxWidth(),
167 | color = Color.White,
168 | ) {
169 | Canvas(
170 | modifier = Modifier
171 | .fillMaxWidth()
172 | .height(100.dp)
173 | ) {
174 | val startPoint = Offset(0f, 0f)
175 | val endPoint = Offset(size.width, size.height)
176 |
177 | // For preview, we need to manually convert Dp to pixels
178 | val crestHeightPx = with(density) { 8.dp.toPx() }
179 | val waveLengthPx = with(density) { 60.dp.toPx() }
180 |
181 | val path = wavyPathSegment(
182 | existingPath = null,
183 | animationProgress = 0f,
184 | crestHeightPx = crestHeightPx,
185 | waveLengthPx = waveLengthPx,
186 | startPoint = startPoint,
187 | endPoint = endPoint,
188 | )
189 |
190 | drawPath(
191 | path = path,
192 | color = Color.Red,
193 | style = Stroke(width = 2.dp.toPx())
194 | )
195 | }
196 | }
197 | }
--------------------------------------------------------------------------------
/waves/src/test/java/com/kenkeremath/uselessui/waves/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.kenkeremath.uselessui.waves
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------