├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.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
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── baseline_gamepad_24.xml
│ │ │ │ ├── baseline_emoji_events_24.xml
│ │ │ │ ├── baseline_electric_bolt_24.xml
│ │ │ │ ├── baseline_emoji_objects_24.xml
│ │ │ │ ├── baseline_cruelty_free_24.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── de
│ │ │ │ └── apuri
│ │ │ │ └── physicslayout
│ │ │ │ ├── ui
│ │ │ │ └── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── GravitySensor.kt
│ │ │ │ ├── samples
│ │ │ │ ├── Simple.kt
│ │ │ │ ├── Grid.kt
│ │ │ │ ├── Shapes.kt
│ │ │ │ ├── Tabs.kt
│ │ │ │ └── StarLauncher.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── de
│ │ │ └── apuri
│ │ │ └── physicslayout
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── de
│ │ └── apuri
│ │ └── physicslayout
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── lib
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── de
│ │ │ └── apuri
│ │ │ └── physicslayout
│ │ │ └── lib
│ │ │ ├── drag
│ │ │ ├── LayoutTouchEvent.kt
│ │ │ ├── DragConfig.kt
│ │ │ ├── TouchModifier.kt
│ │ │ └── DragHandler.kt
│ │ │ ├── simulation
│ │ │ ├── BorderManager.kt
│ │ │ ├── SimulationShape.kt
│ │ │ ├── BodyManager.kt
│ │ │ ├── SimulationBorderFixtures.kt
│ │ │ ├── Clock.kt
│ │ │ ├── SimulationEntity.kt
│ │ │ ├── Simulation.kt
│ │ │ └── SimulationBodyFixtures.kt
│ │ │ ├── Border.kt
│ │ │ ├── conversion
│ │ │ ├── SimulationToLayout.kt
│ │ │ └── LayoutToSimulation.kt
│ │ │ ├── ShapeExt.kt
│ │ │ ├── PhysicsLayout.kt
│ │ │ └── Body.kt
│ ├── test
│ │ └── java
│ │ │ └── de
│ │ │ └── apuri
│ │ │ └── physicslayout
│ │ │ └── lib
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── de
│ │ └── apuri
│ │ └── physicslayout
│ │ └── lib
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
├── gradle.properties
└── build.gradle
├── .idea
├── .gitignore
├── codeStyles
│ ├── codeStyleConfig.xml
│ └── Project.xml
├── compiler.xml
├── kotlinc.xml
├── vcs.xml
├── misc.xml
├── gradle.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
├── LICENSE
├── gradle.properties
├── third_party
└── dyn4j
│ └── LICENSE
├── gradlew.bat
├── README.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/lib/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/lib/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/lib/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | PhysicsLayout
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KlassenKonstantin/ComposePhysicsLayout/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Dec 05 18:42:08 CET 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.ui.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/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 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/drag/LayoutTouchEvent.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.drag
2 |
3 | import androidx.compose.runtime.Immutable
4 | import androidx.compose.ui.geometry.Offset
5 |
6 | @Immutable
7 | data class LayoutTouchEvent(
8 | val pointerId: Long,
9 | val offset: Offset,
10 | val type: TouchType,
11 | )
12 |
13 | @Immutable
14 | enum class TouchType {
15 | DOWN, MOVE, UP
16 | }
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "PhysicsLayout"
16 | include ':app'
17 | include ':lib'
18 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_gamepad_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/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/test/java/de/apuri/physicslayout/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout
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 | }
--------------------------------------------------------------------------------
/lib/src/test/java/de/apuri/physicslayout/lib/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib
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 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_emoji_events_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_electric_bolt_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Konstantin Klassen
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/BorderManager.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import org.dyn4j.geometry.MassType
4 | import org.dyn4j.world.World
5 |
6 | internal class BorderManager(
7 | private val world: World>,
8 | ) {
9 | private var currentBorder: SimulationBorder? = null
10 |
11 | private val borderSimulationEntity = SimulationEntity.Border().apply {
12 | setMassType(MassType.INFINITE)
13 | world.addBody(this)
14 | }
15 |
16 | fun syncBorder(newBorder: SimulationBorder) {
17 | if (currentBorder == newBorder) return
18 |
19 | borderSimulationEntity.updateFrom(newBorder)
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_emoji_objects_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/drag/DragConfig.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.drag
2 |
3 | import androidx.compose.runtime.Immutable
4 |
5 | @Immutable
6 | /**
7 | * Connects the body and the touch point with a [org.dyn4j.dynamics.joint.PinJoint].
8 | * Each pointer creates its own PinJoint. [frequency] defines the oscillation frequency in hz.
9 | * [dampingRatio] defines the damping ratio. [maxForce] defines the maximum force.
10 | */
11 | data class DragConfig(
12 | val frequency: Double = DEF_FREQUENCY,
13 | val dampingRatio: Double = DEF_DAMPING_RATIO,
14 | val maxForce: Double = DEF_MAX_FORCE,
15 | )
16 |
17 | const val DEF_FREQUENCY = 15.0
18 | const val DEF_DAMPING_RATIO = 0.3
19 | const val DEF_MAX_FORCE = 10_000.0
--------------------------------------------------------------------------------
/app/src/androidTest/java/de/apuri/physicslayout/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout
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("de.apuri.physicslayout", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/lib/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
--------------------------------------------------------------------------------
/lib/src/androidTest/java/de/apuri/physicslayout/lib/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib
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("de.apuri.physicslayout.lib.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
20 |
21 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/SimulationShape.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import org.dyn4j.geometry.Vector2
4 |
5 | internal sealed class SimulationShape {
6 | data class Circle(
7 | val radius: Double
8 | ) : SimulationShape()
9 |
10 | data class Rectangle(
11 | val width: Double,
12 | val height: Double,
13 | ) : SimulationShape()
14 |
15 | data class RoundedCornerRectangle(
16 | val width: Double,
17 | val height: Double,
18 | val cornerRadius: Double
19 | ) : SimulationShape()
20 |
21 | data class CutCornerRectangle(
22 | val width: Double,
23 | val height: Double,
24 | val cutLength: Double
25 | ) : SimulationShape()
26 |
27 | data class Generic(
28 | val vertices: List
29 | ) : SimulationShape()
30 | }
--------------------------------------------------------------------------------
/lib/gradle.properties:
--------------------------------------------------------------------------------
1 | #Release config
2 | SONATYPE_HOST=S01
3 | RELEASE_SIGNING_ENABLED=true
4 |
5 | GROUP=io.github.klassenkonstantin
6 | POM_ARTIFACT_ID=physics-layout
7 | VERSION_NAME=0.4.1
8 |
9 | POM_NAME=Compose Physics Layout
10 | POM_DESCRIPTION=Physics based layout for Jetpack Compose.
11 | POM_INCEPTION_YEAR=2022
12 | POM_URL=https://github.com/KlassenKonstantin/ComposePhysicsLayout
13 |
14 | POM_LICENSE_NAME=The Apache Software License, Version 2.0
15 | POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt
16 | POM_LICENSE_DIST=repo
17 |
18 | POM_SCM_URL=https://github.com/KlassenKonstantin/ComposePhysicsLayout
19 | POM_SCM_CONNECTION=scm:git:https://github.com/KlassenKonstantin/ComposePhysicsLayout
20 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com:KlassenKonstantin/ComposePhysicsLayout.git
21 |
22 | POM_DEVELOPER_ID=KlassenKonstantin
23 | POM_DEVELOPER_NAME=Konstantin Klassen
24 | POM_DEVELOPER_URL=https://github.com/KlassenKonstantin
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/Border.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib
2 |
3 | import androidx.compose.runtime.Stable
4 | import androidx.compose.ui.Modifier
5 | import androidx.compose.ui.composed
6 | import androidx.compose.ui.graphics.RectangleShape
7 | import androidx.compose.ui.graphics.Shape
8 | import androidx.compose.ui.layout.onPlaced
9 | import de.apuri.physicslayout.lib.conversion.LocalLayoutToSimulation
10 | import de.apuri.physicslayout.lib.simulation.Simulation
11 |
12 | @Stable
13 | internal fun Modifier.physicsBorder(
14 | shape: Shape? = RectangleShape,
15 | simulation: Simulation,
16 | ) = composed {
17 | val layoutToSimulation = LocalLayoutToSimulation.current
18 |
19 | layoutToSimulation.containerLayoutCoordinates.value?.let {
20 | val simulationBorder = layoutToSimulation.convertBorder(it.size, shape)
21 | simulation.syncSimulationBorder(simulationBorder)
22 | }
23 |
24 | onPlaced {
25 | layoutToSimulation.containerLayoutCoordinates.value = it
26 | }
27 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/BodyManager.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import de.apuri.physicslayout.lib.simulation.SimulationEntity.Body
4 | import org.dyn4j.world.World
5 |
6 | internal class BodyManager(
7 | private val world: World>,
8 | ) {
9 | val bodies: MutableMap = mutableMapOf()
10 |
11 | fun syncBody(id: String, body: SimulationBody?) {
12 | if (body == null) {
13 | removeBody(id)
14 | } else {
15 | upsertBody(id, body)
16 | }
17 | }
18 |
19 | private fun removeBody(id: String) {
20 | bodies[id]?.let {
21 | world.removeBody(it)
22 | }
23 | bodies.remove(id)
24 | }
25 |
26 | private fun upsertBody(id: String, body: SimulationBody) {
27 | bodies.getOrPut(id) {
28 | Body().apply {
29 | translate(body.initialOffset)
30 | world.addBody(this)
31 | }
32 | }.apply {
33 | updateFrom(body)
34 | userData = body
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.ui.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 | )
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/SimulationBorderFixtures.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import org.dyn4j.geometry.Convex
4 | import org.dyn4j.geometry.Geometry
5 |
6 | internal fun SimulationShape.toSimulationBorderFixtures(): List = when (this) {
7 | is SimulationShape.Circle -> toCircleWorldBorder()
8 | is SimulationShape.Rectangle -> toRectangleWorldBorder()
9 | is SimulationShape.Generic -> toGenericWorldBorder()
10 | else -> throw IllegalArgumentException("Unsupported shape")
11 | }
12 |
13 | private fun SimulationShape.Circle.toCircleWorldBorder(): MutableList {
14 | val circle = Geometry.createPolygonalCircle(50, radius)
15 | return Geometry.createLinks(circle.vertices.reversed(), true)
16 | }
17 |
18 | private fun SimulationShape.Rectangle.toRectangleWorldBorder(): MutableList {
19 | val rectangle = Geometry.createRectangle(width, height)
20 | return Geometry.createLinks(rectangle.vertices.reversed(), true)
21 | }
22 |
23 | private fun SimulationShape.Generic.toGenericWorldBorder(): MutableList {
24 | return Geometry.createLinks(vertices.reversed(), true)
25 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/conversion/SimulationToLayout.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.conversion
2 |
3 | import androidx.compose.runtime.Immutable
4 | import androidx.compose.runtime.staticCompositionLocalOf
5 | import androidx.compose.ui.geometry.Offset
6 | import de.apuri.physicslayout.lib.LayoutTransformation
7 | import de.apuri.physicslayout.lib.simulation.SimulationTransformation
8 |
9 | /**
10 | * Handles transformations from simulation to layout space
11 | */
12 | @Immutable
13 | internal class SimulationToLayout(
14 | private val scale: Double
15 | ) {
16 | private fun Double.toLayoutSize() = (this * scale).toFloat()
17 |
18 | fun convertTransformation(offset: Offset, simulationTransformation: SimulationTransformation) =
19 | LayoutTransformation(
20 | translationX = simulationTransformation.translationX.toLayoutSize() - offset.x,
21 | translationY = simulationTransformation.translationY.toLayoutSize() - offset.y,
22 | rotation = simulationTransformation.rotation.toFloat()
23 | )
24 | }
25 |
26 | internal val LocalSimulationToLayout = staticCompositionLocalOf {
27 | throw IllegalStateException("No LayoutToSimulation provided")
28 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_cruelty_free_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/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. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec: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 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/lib/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'org.jetbrains.kotlin.android'
4 | id "com.vanniktech.maven.publish" version "0.22.0"
5 | }
6 |
7 | android {
8 | namespace 'de.apuri.physicslayout.lib'
9 | compileSdk 33
10 |
11 | defaultConfig {
12 | minSdk 24
13 | targetSdk 33
14 |
15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles "consumer-rules.pro"
17 | }
18 |
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 | compileOptions {
26 | sourceCompatibility JavaVersion.VERSION_1_8
27 | targetCompatibility JavaVersion.VERSION_1_8
28 | }
29 | kotlinOptions {
30 | jvmTarget = '1.8'
31 | freeCompilerArgs = ["-Xcontext-receivers"]
32 | }
33 | buildFeatures {
34 | compose = true
35 | }
36 | composeOptions {
37 | kotlinCompilerExtensionVersion '1.4.3'
38 | }
39 | }
40 |
41 | dependencies {
42 | def composeBomAlphas = platform("dev.chrisbanes.compose:compose-bom:2023.02.00-rc02")
43 | implementation platform(composeBomAlphas)
44 | implementation 'androidx.compose.foundation:foundation'
45 | implementation 'org.dyn4j:dyn4j:5.0.1'
46 | testImplementation 'junit:junit:4.13.2'
47 | androidTestImplementation 'androidx.test.ext:junit:1.1.4'
48 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/GravitySensor.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout
2 |
3 | import android.hardware.Sensor
4 | import android.hardware.SensorEvent
5 | import android.hardware.SensorEventListener
6 | import android.hardware.SensorManager
7 | import android.util.Log
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.DisposableEffect
10 | import androidx.compose.ui.geometry.Offset
11 | import androidx.compose.ui.platform.LocalContext
12 | import androidx.core.content.getSystemService
13 |
14 | @Composable
15 | fun GravitySensor(
16 | onGravityChanged: (List) -> Unit
17 | ) {
18 | val context = LocalContext.current
19 | DisposableEffect(Unit) {
20 | val sensorManager = context.getSystemService()!!
21 |
22 | val gravitySensor: Sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
23 |
24 | val gravityListener = object : SensorEventListener {
25 | override fun onSensorChanged(event: SensorEvent) {
26 | val (x, y, z) = event.values
27 | onGravityChanged(listOf(x,y,z))
28 | }
29 |
30 | override fun onAccuracyChanged(p0: Sensor?, p1: Int) {
31 |
32 | }
33 | }
34 |
35 | sensorManager.registerListener(
36 | gravityListener,
37 | gravitySensor,
38 | SensorManager.SENSOR_DELAY_NORMAL,
39 | SensorManager.SENSOR_DELAY_NORMAL
40 | )
41 |
42 | onDispose {
43 | sensorManager.unregisterListener(gravityListener)
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/third_party/dyn4j/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2022, William Bittle
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | * Neither the name of the copyright holder nor the names of its
15 | contributors may be used to endorse or promote products derived from
16 | this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/Clock.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import androidx.compose.runtime.rememberCoroutineScope
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Job
8 | import kotlinx.coroutines.channels.BufferOverflow
9 | import kotlinx.coroutines.delay
10 | import kotlinx.coroutines.flow.MutableSharedFlow
11 | import kotlinx.coroutines.launch
12 |
13 | class Clock internal constructor(
14 | private val scope: CoroutineScope,
15 | autoStart: Boolean
16 | ) {
17 |
18 | internal val frames = MutableSharedFlow(
19 | replay = 0,
20 | extraBufferCapacity = 1,
21 | onBufferOverflow = BufferOverflow.SUSPEND
22 | )
23 |
24 | private var job: Job? = null
25 |
26 | init {
27 | if (autoStart) resume()
28 | }
29 |
30 | fun resume() {
31 | if (job != null) return
32 |
33 | job = scope.launch {
34 | var last = System.nanoTime()
35 | while (true) {
36 | val now = System.nanoTime()
37 | val elapsed = (now - last).toDouble() / 1.0e9
38 | last = now
39 | frames.tryEmit(elapsed)
40 | delay(1)
41 | }
42 | }
43 | }
44 |
45 | fun pause() {
46 | job?.cancel()
47 | job = null
48 | }
49 | }
50 |
51 | @Composable
52 | fun rememberClock(
53 | autoStart: Boolean = true
54 | ): Clock {
55 | val scope = rememberCoroutineScope()
56 | return remember {
57 | Clock(scope, autoStart)
58 | }
59 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/samples/Simple.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.samples
2 |
3 | import androidx.compose.foundation.layout.fillMaxSize
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.foundation.layout.statusBarsPadding
7 | import androidx.compose.foundation.layout.systemBarsPadding
8 | import androidx.compose.foundation.shape.CircleShape
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.filled.Star
11 | import androidx.compose.material3.Card
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Surface
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.unit.dp
20 | import de.apuri.physicslayout.lib.PhysicsLayout
21 | import de.apuri.physicslayout.lib.physicsBody
22 |
23 | @Composable
24 | fun SimpleScreen() {
25 | Surface(
26 | modifier = Modifier.fillMaxSize(),
27 | color = MaterialTheme.colorScheme.background
28 | ) {
29 | PhysicsLayout(
30 | Modifier.systemBarsPadding()
31 | ) {
32 | Card(
33 | modifier = Modifier.physicsBody(
34 | shape = CircleShape,
35 | ).align(Alignment.Center),
36 | shape = CircleShape,
37 | ) {
38 | Icon(
39 | modifier = Modifier
40 | .size(32.dp)
41 | .padding(4.dp),
42 | imageVector = Icons.Default.Star,
43 | contentDescription = "Star",
44 | tint = Color.White
45 | )
46 | }
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/drag/TouchModifier.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.drag
2 |
3 | import androidx.compose.foundation.gestures.awaitEachGesture
4 | import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.geometry.Offset
7 | import androidx.compose.ui.input.pointer.PointerInputChange
8 | import androidx.compose.ui.input.pointer.changedToDown
9 | import androidx.compose.ui.input.pointer.changedToUp
10 | import androidx.compose.ui.input.pointer.pointerInput
11 |
12 | internal fun Modifier.touch(
13 | onTouchEvent: (LayoutTouchEvent) -> Unit
14 | ) = pointerInput(Unit) {
15 | val center = Offset(size.width / 2f, size.height / 2f)
16 | awaitEachGesture {
17 | val changeAfterSlop = awaitTouchSlopOrCancellation(
18 | awaitPointerEvent().changes.first().id,
19 | ) { change, _ ->
20 | change.consume()
21 | }
22 |
23 | fun handlePointerInputChange(change: PointerInputChange) {
24 | val type = when {
25 | change.changedToDown() -> TouchType.DOWN
26 | change.changedToUp() -> TouchType.UP
27 | else -> TouchType.MOVE
28 | }
29 |
30 | onTouchEvent(
31 | LayoutTouchEvent(
32 | pointerId = change.id.value,
33 | offset = change.position - center,
34 | type = type,
35 | )
36 | )
37 | }
38 |
39 | if (changeAfterSlop != null) {
40 | handlePointerInputChange(changeAfterSlop)
41 | do {
42 | val event = awaitPointerEvent()
43 | event.changes.forEach { change ->
44 | handlePointerInputChange(change)
45 | }
46 | } while (!event.changes.all { it.changedToUp() })
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/SimulationEntity.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import org.dyn4j.geometry.MassType
4 | import org.dyn4j.dynamics.Body as LibBody
5 |
6 | internal sealed class SimulationEntity : LibBody() {
7 |
8 | protected abstract fun updateFrom(current: T?, new: T)
9 |
10 | fun updateFrom(new: T) = updateFrom(this.userData as? T, new)
11 |
12 | class Body : SimulationEntity() {
13 | fun getTransformation() = SimulationTransformation(
14 | translationX = transform.translationX,
15 | translationY = transform.translationY,
16 | rotation = transform.rotation.toDegrees()
17 | )
18 |
19 | override fun updateFrom(current: SimulationBody?, new: SimulationBody) {
20 | angularDamping = new.bodyConfig.angularDamping.toDouble()
21 |
22 | if (new.shape != current?.shape) {
23 | removeAllFixtures()
24 | new.shape.toSimulationBodyFixtures().forEach {
25 | addFixture(
26 | it,
27 | new.bodyConfig.density.toDouble(),
28 | new.bodyConfig.friction.toDouble(),
29 | new.bodyConfig.restitution.toDouble(),
30 | )
31 | }
32 | } else if (new.bodyConfig != current.bodyConfig) {
33 | fixtures.forEach {
34 | it.density = new.bodyConfig.density.toDouble()
35 | it.friction = new.bodyConfig.friction.toDouble()
36 | it.restitution = new.bodyConfig.restitution.toDouble()
37 | }
38 | }
39 |
40 | setMass(if (new.bodyConfig.isStatic) MassType.INFINITE else MassType.NORMAL)
41 | }
42 | }
43 |
44 | class Border : SimulationEntity() {
45 | override fun updateFrom(current: SimulationBorder?, new: SimulationBorder) {
46 | removeAllFixtures()
47 | new.shape?.toSimulationBorderFixtures()?.forEach {
48 | addFixture(it)
49 | }
50 | updateMass()
51 | }
52 | }
53 | }
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/drag/DragHandler.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.drag
2 |
3 | import de.apuri.physicslayout.lib.simulation.SimulationEntity
4 | import de.apuri.physicslayout.lib.simulation.SimulationTouchEvent
5 | import org.dyn4j.dynamics.joint.Joint
6 | import org.dyn4j.dynamics.joint.PinJoint
7 | import org.dyn4j.world.World
8 |
9 | internal interface DragHandler {
10 | fun drag(
11 | body: SimulationEntity.Body,
12 | touchEvent: SimulationTouchEvent,
13 | dragConfig: DragConfig
14 | )
15 | }
16 |
17 | internal class DefaultDragHandler(
18 | private val world: World>
19 | ) : DragHandler {
20 | private val joints = mutableMapOf>()
21 |
22 | override fun drag(
23 | body: SimulationEntity.Body,
24 | touchEvent: SimulationTouchEvent,
25 | dragConfig: DragConfig
26 | ) {
27 | val key = JointKey(body, touchEvent.pointerId)
28 | when (touchEvent.type) {
29 | TouchType.DOWN -> {
30 | getOrPutJoint(key, touchEvent, dragConfig)
31 | }
32 |
33 | TouchType.MOVE -> {
34 | getOrPutJoint(key, touchEvent, dragConfig).apply {
35 | target = body.getWorldPoint(touchEvent.offset)
36 | springFrequency = dragConfig.frequency
37 | springDampingRatio = dragConfig.dampingRatio
38 | maximumSpringForce = dragConfig.maxForce
39 | }
40 |
41 | }
42 |
43 | TouchType.UP -> {
44 | world.removeJoint(joints.remove(key) as Joint>)
45 | }
46 | }
47 | }
48 |
49 | private fun getOrPutJoint(
50 | jointKey: JointKey,
51 | touchEvent: SimulationTouchEvent,
52 | dragConfig: DragConfig
53 | ) = joints.getOrPut(jointKey) {
54 | PinJoint(
55 | jointKey.body,
56 | jointKey.body.getWorldPoint(touchEvent.offset),
57 | ).apply {
58 | isSpringEnabled = true
59 | springFrequency = dragConfig.frequency
60 | springDampingRatio = dragConfig.dampingRatio
61 | maximumSpringForce = dragConfig.maxForce
62 | }.also {
63 | world.addJoint(it as Joint>)
64 | }
65 | }
66 | }
67 |
68 | private data class JointKey(
69 | val body: SimulationEntity.Body,
70 | val pointerId: Long,
71 | )
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | }
5 |
6 | android {
7 | namespace 'de.apuri.physicslayout'
8 | compileSdk 33
9 |
10 | defaultConfig {
11 | applicationId "de.apuri.physicslayout"
12 | minSdk 24
13 | targetSdk 33
14 | versionCode 1
15 | versionName "1.0"
16 |
17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18 | vectorDrawables {
19 | useSupportLibrary true
20 | }
21 | }
22 |
23 | buildTypes {
24 | release {
25 | debuggable false
26 | minifyEnabled true
27 | shrinkResources true
28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility JavaVersion.VERSION_1_8
33 | targetCompatibility JavaVersion.VERSION_1_8
34 | }
35 | kotlinOptions {
36 | jvmTarget = '1.8'
37 | }
38 | buildFeatures {
39 | compose true
40 | }
41 | composeOptions {
42 | kotlinCompilerExtensionVersion '1.4.3'
43 | }
44 | packagingOptions {
45 | resources {
46 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
47 | }
48 | }
49 | }
50 |
51 | dependencies {
52 | def composeBomAlphas = platform("dev.chrisbanes.compose:compose-bom:2023.02.00-rc02")
53 | implementation project(":lib")
54 | implementation 'androidx.core:core-ktx:1.9.0'
55 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0'
56 | implementation "androidx.navigation:navigation-compose:2.5.3"
57 | implementation 'androidx.activity:activity-compose:1.6.1'
58 | implementation platform(composeBomAlphas)
59 | implementation 'androidx.compose.ui:ui'
60 | implementation 'androidx.compose.ui:ui-graphics'
61 | implementation 'androidx.compose.ui:ui-tooling-preview'
62 | implementation "androidx.compose.material3:material3:1.1.0-alpha08"
63 | testImplementation 'junit:junit:4.13.2'
64 | androidTestImplementation 'androidx.test.ext:junit:1.1.4'
65 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
66 | androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
67 | androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
68 | debugImplementation 'androidx.compose.ui:ui-tooling'
69 | debugImplementation 'androidx.compose.ui:ui-test-manifest'
70 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/ShapeExt.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib
2 |
3 | import android.graphics.PathMeasure
4 | import androidx.compose.foundation.shape.CircleShape
5 | import androidx.compose.foundation.shape.CutCornerShape
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.ui.geometry.Offset
8 | import androidx.compose.ui.geometry.Size
9 | import androidx.compose.ui.graphics.Path
10 | import androidx.compose.ui.graphics.RectangleShape
11 | import androidx.compose.ui.graphics.Shape
12 | import androidx.compose.ui.graphics.addOutline
13 | import androidx.compose.ui.graphics.asAndroidPath
14 | import androidx.compose.ui.unit.Density
15 | import androidx.compose.ui.unit.LayoutDirection
16 |
17 | internal fun Shape.toPoints(
18 | size: Size,
19 | layoutDirection: LayoutDirection,
20 | density: Density,
21 | steps: Int
22 | ): List {
23 | val outline = createOutline(size, layoutDirection, density)
24 | val path = Path().apply { addOutline(outline) }
25 | if (!path.isConvex) {
26 | throw IllegalArgumentException("Only convex shapes are supported")
27 | }
28 | val pm = PathMeasure().apply { setPath(path.asAndroidPath(), true) }
29 | val stepSize = pm.length / steps
30 | val coordinates = FloatArray(2)
31 | val offset = Offset(size.width / 2, size.height / 2)
32 | return buildList {
33 | (0 until steps).forEach {
34 | pm.getPosTan(it * stepSize, coordinates, null)
35 | add(Offset(coordinates[0], coordinates[1]) - offset)
36 | }
37 | }.let {
38 | // Points come out in reversed order for RoundedCornerShape?
39 | if (this is RoundedCornerShape) it.reversed() else it
40 | }
41 | }
42 |
43 | internal fun RoundedCornerShape.toRadius(width: Float, height: Float, density: Density): Float {
44 | return topStart.toPx(
45 | Size(width, height),
46 | density
47 | )
48 | }
49 |
50 | internal fun CutCornerShape.toCutLength(width: Float, height: Float, density: Density): Float {
51 | return topStart.toPx(
52 | Size(width, height),
53 | density
54 | )
55 | }
56 |
57 | internal fun Shape.isSupported() = isCircle() || isRectangle() || isRoundedCornerRectangle() || isCutCornerRectangle()
58 | internal fun Shape.isCircle() = this == CircleShape
59 | internal fun Shape.isRectangle() = this == RectangleShape
60 | internal fun Shape.isRoundedCornerRectangle() = this is RoundedCornerShape
61 | internal fun Shape.isCutCornerRectangle() = this is CutCornerShape
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.SideEffect
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.graphics.toArgb
15 | import androidx.compose.ui.platform.LocalContext
16 | import androidx.compose.ui.platform.LocalView
17 | import androidx.core.view.WindowCompat
18 |
19 | private val DarkColorScheme = darkColorScheme(
20 | primary = Purple80,
21 | secondary = PurpleGrey80,
22 | tertiary = Pink80
23 | )
24 |
25 | private val LightColorScheme = lightColorScheme(
26 | primary = Purple40,
27 | secondary = PurpleGrey40,
28 | tertiary = Pink40
29 |
30 | /* Other default colors to override
31 | background = Color(0xFFFFFBFE),
32 | surface = Color(0xFFFFFBFE),
33 | onPrimary = Color.White,
34 | onSecondary = Color.White,
35 | onTertiary = Color.White,
36 | onBackground = Color(0xFF1C1B1F),
37 | onSurface = Color(0xFF1C1B1F),
38 | */
39 | )
40 |
41 | @Composable
42 | fun PhysicsLayoutTheme(
43 | darkTheme: Boolean = isSystemInDarkTheme(),
44 | // Dynamic color is available on Android 12+
45 | dynamicColor: Boolean = true,
46 | content: @Composable () -> Unit
47 | ) {
48 | val colorScheme = when {
49 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
50 | val context = LocalContext.current
51 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
52 | }
53 |
54 | darkTheme -> DarkColorScheme
55 | else -> LightColorScheme
56 | }
57 | val view = LocalView.current
58 | if (!view.isInEditMode) {
59 | SideEffect {
60 | val window = (view.context as Activity).window
61 | window.statusBarColor = Color.Transparent.toArgb()
62 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
63 | }
64 | }
65 |
66 | MaterialTheme(
67 | colorScheme = colorScheme,
68 | typography = Typography,
69 | content = content
70 | )
71 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/PhysicsLayout.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("PrivatePropertyName")
2 |
3 | package de.apuri.physicslayout.lib
4 |
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.BoxScope
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.CompositionLocalProvider
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.runtime.staticCompositionLocalOf
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.RectangleShape
13 | import androidx.compose.ui.graphics.Shape
14 | import androidx.compose.ui.platform.LocalDensity
15 | import androidx.compose.ui.unit.Dp
16 | import androidx.compose.ui.unit.dp
17 | import de.apuri.physicslayout.lib.conversion.LayoutToSimulation
18 | import de.apuri.physicslayout.lib.conversion.LocalLayoutToSimulation
19 | import de.apuri.physicslayout.lib.conversion.LocalSimulationToLayout
20 | import de.apuri.physicslayout.lib.conversion.SimulationToLayout
21 | import de.apuri.physicslayout.lib.simulation.Simulation
22 | import de.apuri.physicslayout.lib.simulation.rememberSimulation
23 |
24 | private val DEFAULT_SCALE = 32.dp
25 |
26 | /**
27 | * This is the entry to the physics world. [shape] defines the border of the simulation or `null` if no borders are
28 | * wanted. [scale] defines how many [Dp] should be considered one meter. Bodies should not be too small. As a rule of
29 | * thumb use at least one meter for width and height.
30 | */
31 | @Composable
32 | fun PhysicsLayout(
33 | modifier: Modifier = Modifier,
34 | shape: Shape? = RectangleShape,
35 | scale: Dp = DEFAULT_SCALE,
36 | simulation: Simulation = rememberSimulation(),
37 | content: @Composable BoxScope.() -> Unit
38 | ) {
39 | val density = LocalDensity.current
40 | val scalePx = density.run { scale.toPx().toDouble() }
41 |
42 | val layoutToSimulation = remember(density) {
43 | LayoutToSimulation(density, scalePx)
44 | }
45 |
46 | val simulationToLayout = remember(scalePx) {
47 | SimulationToLayout(scalePx)
48 | }
49 |
50 | CompositionLocalProvider(
51 | LocalSimulation provides simulation,
52 | LocalLayoutToSimulation provides layoutToSimulation,
53 | LocalSimulationToLayout provides simulationToLayout,
54 | ) {
55 | Box(
56 | modifier = modifier.physicsBorder(
57 | shape = shape,
58 | simulation = simulation,
59 | ),
60 | content = content,
61 | )
62 | }
63 | }
64 |
65 | val LocalSimulation = staticCompositionLocalOf {
66 | throw IllegalStateException("No Simulation provided")
67 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.foundation.clickable
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.systemBarsPadding
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.foundation.lazy.items
12 | import androidx.compose.material3.ListItem
13 | import androidx.compose.material3.Text
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.Modifier
16 | import androidx.core.view.WindowCompat
17 | import androidx.navigation.compose.NavHost
18 | import androidx.navigation.compose.composable
19 | import androidx.navigation.compose.rememberNavController
20 | import de.apuri.physicslayout.samples.FlyingTabsScreen
21 | import de.apuri.physicslayout.samples.ShapesScreen
22 | import de.apuri.physicslayout.samples.GridScreen
23 | import de.apuri.physicslayout.samples.SimpleScreen
24 | import de.apuri.physicslayout.samples.StarLauncherScreen
25 | import de.apuri.physicslayout.ui.theme.PhysicsLayoutTheme
26 |
27 | class MainActivity : ComponentActivity() {
28 | override fun onCreate(savedInstanceState: Bundle?) {
29 | WindowCompat.setDecorFitsSystemWindows(window, false)
30 | super.onCreate(savedInstanceState)
31 | setContent {
32 | PhysicsLayoutTheme {
33 | val navController = rememberNavController()
34 | Box(
35 | Modifier.fillMaxSize()
36 | ) {
37 | NavHost(
38 | modifier = Modifier,
39 | navController = navController,
40 | startDestination = "samplePicker"
41 | ) {
42 | composable("samplePicker") { SamplePicker { navController.navigate(it) } }
43 | composable("Star Launcher") { StarLauncherScreen() }
44 | composable("Shapes") { ShapesScreen() }
45 | composable("Flying Tabs") { FlyingTabsScreen() }
46 | composable("Grid") { GridScreen() }
47 | composable("Simple") { SimpleScreen() }
48 | }
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
55 | @Composable
56 | fun SamplePicker(onSamplePicked: (String) -> Unit) {
57 | val samples = listOf(
58 | "Star Launcher",
59 | "Shapes",
60 | "Flying Tabs",
61 | "Grid",
62 | "Simple",
63 | )
64 |
65 | LazyColumn(
66 | modifier = Modifier.systemBarsPadding()
67 | ) {
68 | items(samples) {
69 | SampleItem(it) { onSamplePicked(it) }
70 | }
71 | }
72 | }
73 |
74 | @Composable
75 | fun SampleItem(id: String, onSampleItemClicked: () -> Unit) {
76 | ListItem(
77 | headlineContent = { Text(id) },
78 | modifier = Modifier.clickable {
79 | onSampleItemClicked()
80 | }
81 | )
82 | }
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/Simulation.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.Immutable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.Stable
7 | import androidx.compose.runtime.mutableStateMapOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.ui.geometry.Offset
10 | import de.apuri.physicslayout.lib.BodyConfig
11 | import de.apuri.physicslayout.lib.drag.DefaultDragHandler
12 | import de.apuri.physicslayout.lib.drag.DragConfig
13 | import de.apuri.physicslayout.lib.drag.TouchType
14 | import org.dyn4j.geometry.Vector2
15 | import org.dyn4j.world.World
16 |
17 | @Stable
18 | class Simulation internal constructor(
19 | private val world: World>,
20 | private val clock: Clock,
21 | ) {
22 | internal val transformations = mutableStateMapOf()
23 |
24 | private val bodyManager = BodyManager(world)
25 | private val borderManager = BorderManager(world)
26 | private val dragHandler = DefaultDragHandler(world)
27 |
28 | fun setGravity(offset: Offset) {
29 | world.gravity = Vector2(offset.x.toDouble(), offset.y.toDouble())
30 | }
31 |
32 | internal suspend fun run() {
33 | clock.frames.collect { elapsed ->
34 | world.update(elapsed)
35 | updateTransformations()
36 | }
37 | }
38 |
39 | private fun updateTransformations() {
40 | bodyManager.bodies.mapValues {
41 | it.value.getTransformation()
42 | }.also {
43 | transformations.putAll(it)
44 | }
45 | }
46 |
47 | internal fun syncSimulationBorder(simulationBorder: SimulationBorder) {
48 | borderManager.syncBorder(simulationBorder)
49 | }
50 |
51 | internal fun syncSimulationBody(id: String, body: SimulationBody?) {
52 | bodyManager.syncBody(id, body)
53 | }
54 |
55 | internal fun drag(bodyId: String, touchEvent: SimulationTouchEvent, dragConfig: DragConfig) {
56 | bodyManager.bodies[bodyId]?.let {
57 | dragHandler.drag(it, touchEvent, dragConfig)
58 | }
59 | }
60 | }
61 |
62 | @Composable
63 | fun rememberSimulation(clock: Clock = rememberClock()): Simulation {
64 | val simulation = remember(clock) {
65 | Simulation(createDefaultWorld(), clock)
66 | }
67 |
68 | LaunchedEffect(simulation) {
69 | simulation.run()
70 | }
71 |
72 | return simulation
73 | }
74 |
75 | private const val EARTH_GRAVITY = 9.81
76 |
77 | private fun createDefaultWorld() = World>().apply {
78 | gravity = Vector2(0.0, EARTH_GRAVITY)
79 | settings.apply {
80 | isAtRestDetectionEnabled = false
81 | stepFrequency = 1.0 / 90
82 | }
83 | }
84 |
85 | internal data class SimulationBorder(
86 | val width: Double,
87 | val height: Double,
88 | val shape: SimulationShape?
89 | )
90 |
91 | internal data class SimulationBody(
92 | val width: Double,
93 | val height: Double,
94 | val shape: SimulationShape,
95 | val initialOffset: Vector2,
96 | val bodyConfig: BodyConfig,
97 | )
98 |
99 | internal data class SimulationTouchEvent(
100 | val pointerId: Long,
101 | val offset: Vector2,
102 | val type: TouchType
103 | )
104 |
105 | @Immutable
106 | internal data class SimulationTransformation(
107 | val translationX: Double,
108 | val translationY: Double,
109 | val rotation: Double,
110 | )
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/simulation/SimulationBodyFixtures.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.simulation
2 |
3 | import org.dyn4j.geometry.Convex
4 | import org.dyn4j.geometry.Geometry
5 | import org.dyn4j.geometry.Vector2
6 | import org.dyn4j.geometry.hull.GiftWrap
7 | import kotlin.math.sqrt
8 |
9 | internal fun SimulationShape.toSimulationBodyFixtures(): List = when (this) {
10 | is SimulationShape.Circle -> listOf(Geometry.createCircle(radius))
11 | is SimulationShape.Rectangle -> listOf(Geometry.createRectangle(width, height))
12 | is SimulationShape.RoundedCornerRectangle -> toRoundedRectShape()
13 | is SimulationShape.CutCornerRectangle -> toCutCornerRectShape()
14 | is SimulationShape.Generic -> createFromVertices(vertices)
15 | }
16 |
17 | private fun createFromVertices(vertices: List): List {
18 | return listOf(
19 | Geometry.createPolygon(*GiftWrap().generate(vertices).toTypedArray())
20 | )
21 | }
22 |
23 | private fun SimulationShape.RoundedCornerRectangle.toRoundedRectShape(): List {
24 | val fixtures = mutableListOf()
25 | val radius = cornerRadius
26 |
27 | val halfBodyWidth = width / 2
28 | val halfBodyHeight = height / 2
29 |
30 | // Top left
31 | fixtures += Geometry.createCircle(radius).apply {
32 | translate(-halfBodyWidth + radius, -halfBodyHeight + radius)
33 | }
34 |
35 | // Top right
36 | fixtures += Geometry.createCircle(radius).apply {
37 | translate(halfBodyWidth - radius, -halfBodyHeight + radius)
38 | }
39 |
40 | // Bottom left
41 | fixtures += Geometry.createCircle(radius).apply {
42 | translate(-halfBodyWidth + radius, halfBodyHeight - radius)
43 | }
44 |
45 | // Bottom right
46 | fixtures += Geometry.createCircle(radius).apply {
47 | translate(halfBodyWidth - radius, halfBodyHeight - radius)
48 | }
49 |
50 | // Rect A
51 | fixtures += Geometry.createRectangle(
52 | width - 2 * radius,
53 | height,
54 | )
55 |
56 | // Rect B
57 | fixtures += Geometry.createRectangle(
58 | width,
59 | height - 2 * radius,
60 | )
61 |
62 | return fixtures
63 | }
64 |
65 | private fun SimulationShape.CutCornerRectangle.toCutCornerRectShape(): List {
66 | val fixtures = mutableListOf()
67 | val cutLength = cutLength
68 | val legLength = (sqrt(2.0 / 2)) * cutLength
69 |
70 | val halfBodyWidth = width / 2
71 | val halfBodyHeight = height / 2
72 |
73 | // Top left
74 | fixtures += Geometry.createTriangle(Vector2(), Vector2(-legLength, 0.0), Vector2(0.0, -legLength)).apply {
75 | translate(-halfBodyWidth + legLength, -halfBodyHeight + legLength)
76 | }
77 |
78 | // Top right
79 | fixtures += Geometry.createTriangle(Vector2(), Vector2(0.0, -legLength), Vector2(legLength, 0.0)).apply {
80 | translate(halfBodyWidth - legLength, -halfBodyHeight + legLength)
81 | }
82 |
83 | // Bottom left
84 | fixtures += Geometry.createTriangle(Vector2(), Vector2(0.0, legLength), Vector2(-legLength, 0.0)).apply {
85 | translate(-halfBodyWidth + legLength, halfBodyHeight - legLength)
86 | }
87 |
88 | // Bottom right
89 | fixtures += Geometry.createTriangle(Vector2(), Vector2(legLength, 0.0), Vector2(0.0, legLength)).apply {
90 | translate(halfBodyWidth - legLength, halfBodyHeight - legLength)
91 | }
92 |
93 | // Rect A
94 | fixtures += Geometry.createRectangle(
95 | width - 2 * legLength,
96 | height,
97 | )
98 |
99 | // Rect B
100 | fixtures += Geometry.createRectangle(
101 | width,
102 | height - 2 * legLength,
103 | )
104 |
105 | return fixtures
106 | }
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | xmlns:android
15 |
16 | ^$
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | xmlns:.*
26 |
27 | ^$
28 |
29 |
30 | BY_NAME
31 |
32 |
33 |
34 |
35 |
36 |
37 | .*:id
38 |
39 | http://schemas.android.com/apk/res/android
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | .*:name
49 |
50 | http://schemas.android.com/apk/res/android
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | name
60 |
61 | ^$
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | style
71 |
72 | ^$
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | .*
82 |
83 | ^$
84 |
85 |
86 | BY_NAME
87 |
88 |
89 |
90 |
91 |
92 |
93 | .*
94 |
95 | http://schemas.android.com/apk/res/android
96 |
97 |
98 | ANDROID_ATTRIBUTE_ORDER
99 |
100 |
101 |
102 |
103 |
104 |
105 | .*
106 |
107 | .*
108 |
109 |
110 | BY_NAME
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Physics Layout
2 | 
3 |
4 | This library offers a [dyn4j](https://www.dyn4j.org) wrapper for [Jetpack Compose](https://developer.android.com/jetpack/compose).
5 |
6 | ## 🚧 Experimental 🚧
7 | Before reaching version 1.0, this library is considered experimental, which means that there is no guaranteed backwards compatibility between versions. Signatures, interfaces, names, etc. may and will most likely change.
8 |
9 | ## Sample App
10 | https://user-images.githubusercontent.com/1836066/206856910-d2172e7e-64da-454e-99b9-8171cf5f5eeb.mov
11 |
12 | ## Download
13 | ```
14 | dependencies {
15 | implementation 'io.github.klassenkonstantin:physics-layout:'
16 | }
17 | ```
18 |
19 | # How to use
20 | To get started, create a `PhysicsLayout` and add arbitrary content to it. Add the `physicsBody` modifier to Composables that should be part of the physics simulation.
21 |
22 | ## PhysicsLayout
23 | ```kotlin
24 | @Composable
25 | fun PhysicsLayout(
26 | modifier: Modifier = Modifier,
27 | shape: Shape? = RectangleShape,
28 | scale: Dp = DEFAULT_SCALE,
29 | simulation: Simulation = rememberSimulation(),
30 | content: @Composable BoxScope.() -> Unit
31 | )
32 | ```
33 | - `shape`: The shape of the outer border of the `PhysicsLayout`
34 | - `scale`: How many Dps should be considered one meter. Bodies shouldn't be smaller than one meter
35 | - `simulation`: Does the mapping between layout and physics engine
36 | - `content`: The arbitrary layout
37 |
38 | ## physicsBody modifier
39 | ```kotlin
40 | fun Modifier.physicsBody(
41 | id: String? = null,
42 | shape: Shape = RectangleShape,
43 | bodyConfig: BodyConfig = BodyConfig(),
44 | dragConfig: DragConfig? = null,
45 | )
46 | ```
47 | - `id`: The id the body should have in the simulation. Useful for operations that act directly on bodies (not yet supported).
48 | - `shape`: Describes the outer bounds of the body. Supported shapes are:
49 | - [RectangleShape](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/package-summary#RectangleShape())
50 | - [CircleShape](https://developer.android.com/reference/kotlin/androidx/compose/foundation/shape/package-summary#CircleShape())
51 | - [RoundedCornerShape](https://developer.android.com/reference/kotlin/androidx/compose/foundation/shape/RoundedCornerShape)
52 | - [CutCornerShape](https://developer.android.com/reference/kotlin/androidx/compose/foundation/shape/CutCornerShape)
53 | - `bodyConfig`: Configures properties of the body
54 | - `dragConfig`: Set a `DragConfig` to enable drag support, or `null` to disable dragging
55 |
56 | ## Clock
57 | By default `Simulation` uses a default `Clock` which automatically starts running. To pause and resume a `Clock`, create an instance with `rememberClock()` and pass that to the `Simulation`.
58 |
59 | ### Example usage
60 | ```kotlin
61 | @Composable
62 | fun SimpleScreen() {
63 | Surface(
64 | modifier = Modifier.fillMaxSize(),
65 | color = MaterialTheme.colorScheme.background
66 | ) {
67 | PhysicsLayout {
68 | Card(
69 | modifier = Modifier.physicsBody(
70 | shape = CircleShape,
71 | ).align(Alignment.Center),
72 | shape = CircleShape,
73 | ) {
74 | Icon(
75 | modifier = Modifier
76 | .size(32.dp)
77 | .padding(4.dp),
78 | imageVector = Icons.Default.Star,
79 | contentDescription = "Star",
80 | tint = Color.White
81 | )
82 | }
83 | }
84 | }
85 | }
86 | ```
87 | This example adds a ball with a star in the center of the layout, which then starts falling to the ground.
88 |
89 | > Note: The `shape` must be set on both the body modifier and the `Card`.
90 |
91 | ### Change gravity
92 | If you need to change the gravity of the simulated world, use `Simulation.setGravity`
93 |
94 | ## Caveats, notes, missing features
95 | - I don't think Compose was made to display hundreds of Composables at the same time. So maybe it's not a good idea to build a particle system out of this.
96 | - In general, what is true for all of Compose is especially true for this Layout: **Release builds perform way better than debug builds**.
97 | - State is not restored on config changes 😱.
98 | - Currently there is no way to observe bodies / collosions / etc.
99 | - Not tested with scrolling containers
100 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/Body.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib
2 |
3 | import androidx.compose.runtime.DisposableEffect
4 | import androidx.compose.runtime.Immutable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.runtime.setValue
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.composed
12 | import androidx.compose.ui.geometry.Offset
13 | import androidx.compose.ui.graphics.RectangleShape
14 | import androidx.compose.ui.graphics.Shape
15 | import androidx.compose.ui.graphics.graphicsLayer
16 | import androidx.compose.ui.layout.LayoutCoordinates
17 | import androidx.compose.ui.layout.onPlaced
18 | import de.apuri.physicslayout.lib.conversion.LocalLayoutToSimulation
19 | import de.apuri.physicslayout.lib.conversion.LocalSimulationToLayout
20 | import de.apuri.physicslayout.lib.drag.DragConfig
21 | import de.apuri.physicslayout.lib.drag.touch
22 | import java.util.UUID
23 |
24 | /**
25 | * Introduces the Composable this Modifier is applied to to the physics world.
26 | *
27 | * This must be used on a Composable that is a direct or indirect child of a PhysicsLayout or else an Exception is
28 | * thrown.
29 | *
30 | * [shape] defines the shape of this body. This should be the same as the shape of the Composable this is applied to in
31 | * most cases. If [dragConfig] is not `null`, the body can be dragged by the user and behaves as defined in [DragConfig].
32 | */
33 | fun Modifier.physicsBody(
34 | id: String? = null,
35 | shape: Shape = RectangleShape,
36 | bodyConfig: BodyConfig = BodyConfig(),
37 | dragConfig: DragConfig? = null,
38 | ) = composed {
39 | val bodyId = id ?: remember { UUID.randomUUID().toString() }
40 | val simulation = LocalSimulation.current
41 | val layoutToSimulation = LocalLayoutToSimulation.current
42 | val simulationToLayout = LocalSimulationToLayout.current
43 | val layoutOffset = remember { mutableStateOf(Offset.Zero) }
44 | var coordinates by remember { mutableStateOf(null) }
45 |
46 | LaunchedEffect(coordinates, bodyConfig) {
47 | coordinates?.let {
48 | val (body, offsetFromCenter) = layoutToSimulation.convertBody(
49 | coordinates = it,
50 | shape = shape,
51 | bodyConfig = bodyConfig,
52 | )
53 | layoutOffset.value = offsetFromCenter
54 | simulation.syncSimulationBody(bodyId, body)
55 | }
56 | }
57 |
58 | DisposableEffect(id) {
59 | onDispose {
60 | simulation.syncSimulationBody(bodyId, null)
61 | }
62 | }
63 |
64 | onPlaced {
65 | coordinates = it
66 | }
67 | .graphicsLayer {
68 | simulation.transformations[bodyId]?.let {
69 | val transformation = simulationToLayout.convertTransformation(
70 | offset = layoutOffset.value,
71 | simulationTransformation = it
72 | )
73 | translationX = transformation.translationX
74 | translationY = transformation.translationY
75 | rotationZ = transformation.rotation
76 | }
77 | }
78 | .then(
79 | if (dragConfig != null) {
80 | touch {
81 | simulation.drag(
82 | bodyId = bodyId,
83 | touchEvent = layoutToSimulation.convertTouchEvent(it),
84 | dragConfig = dragConfig
85 | )
86 | }
87 | } else Modifier
88 | )
89 | }
90 |
91 | /**
92 | * Configures properties of the body
93 | */
94 | @Immutable
95 | data class BodyConfig(
96 | /**
97 | * Whether or not this body is movable. Set to `false` for walls or floors
98 | */
99 | val isStatic: Boolean = false,
100 |
101 | /**
102 | * The angular damping, see [org.dyn4j.dynamics.PhysicsBody.setAngularDamping]
103 | */
104 | val angularDamping: Float = 0.7f,
105 |
106 | /**
107 | * The density, see [org.dyn4j.dynamics.PhysicsBody.addFixture]
108 | */
109 | val density: Float = 1.0f,
110 |
111 | /**
112 | * The friction, see [org.dyn4j.dynamics.PhysicsBody.addFixture]
113 | */
114 | val friction: Float = 0.2f,
115 |
116 | /**
117 | * The restitution, see [org.dyn4j.dynamics.PhysicsBody.addFixture]
118 | */
119 | val restitution: Float = 0.4f,
120 | )
121 |
122 | @Immutable
123 | internal data class LayoutTransformation(
124 | val translationX: Float,
125 | val translationY: Float,
126 | val rotation: Float,
127 | )
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/src/main/java/de/apuri/physicslayout/lib/conversion/LayoutToSimulation.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.lib.conversion
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.runtime.Stable
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.staticCompositionLocalOf
7 | import androidx.compose.ui.geometry.Offset
8 | import androidx.compose.ui.geometry.Size
9 | import androidx.compose.ui.graphics.Shape
10 | import androidx.compose.ui.layout.LayoutCoordinates
11 | import androidx.compose.ui.layout.positionInParent
12 | import androidx.compose.ui.unit.Density
13 | import androidx.compose.ui.unit.IntSize
14 | import androidx.compose.ui.unit.LayoutDirection
15 | import de.apuri.physicslayout.lib.BodyConfig
16 | import de.apuri.physicslayout.lib.drag.LayoutTouchEvent
17 | import de.apuri.physicslayout.lib.isCircle
18 | import de.apuri.physicslayout.lib.isRectangle
19 | import de.apuri.physicslayout.lib.isRoundedCornerRectangle
20 | import de.apuri.physicslayout.lib.isSupported
21 | import de.apuri.physicslayout.lib.simulation.SimulationBody
22 | import de.apuri.physicslayout.lib.simulation.SimulationBorder
23 | import de.apuri.physicslayout.lib.simulation.SimulationShape
24 | import de.apuri.physicslayout.lib.simulation.SimulationTouchEvent
25 | import de.apuri.physicslayout.lib.toPoints
26 | import de.apuri.physicslayout.lib.toRadius
27 | import org.dyn4j.geometry.Vector2
28 |
29 | /**
30 | * The number of path segments a generic shape should consist of
31 | */
32 | private const val PATH_SEGMENTS = 100
33 |
34 | /**
35 | * Handles transformations from layout to simulation space
36 | */
37 | @Stable
38 | internal class LayoutToSimulation(
39 | private val density: Density,
40 | private val scale: Double,
41 | ) {
42 |
43 | var containerLayoutCoordinates = mutableStateOf(null)
44 |
45 | fun convertBody(
46 | coordinates: LayoutCoordinates,
47 | shape: Shape,
48 | bodyConfig: BodyConfig
49 | ): Pair = containerLayoutCoordinates.value?.let { containerLayoutCoordinates ->
50 | /**
51 | * Width and height of the composable
52 | */
53 | val (lw, lh) = coordinates.size
54 |
55 | /**
56 | * Width and height of the [PhysicsLayout]
57 | */
58 | val (bw, bh) = containerLayoutCoordinates.size
59 |
60 | /**
61 | * Half width and height of the [PhysicsLayout]
62 | */
63 | val (bwh, bhh) = IntSize(bw / 2, bh / 2)
64 |
65 | /**
66 | * The local position of the composable in the [LayoutCoordinates] of the [PhysicsLayout]
67 | */
68 | val (lx, ly) = containerLayoutCoordinates.localPositionOf(
69 | coordinates.parentCoordinates!!,
70 | coordinates.positionInParent()
71 | )
72 |
73 | /**
74 | * Position of the composable with the origin in the center of the [PhysicsLayout].
75 | * We need that because the world in the physics engine has its origin in the center.
76 | */
77 | val positionFromCenter = Offset(
78 | (lx * bwh - lx * -bwh + bw * -bwh) / bw + lw / 2,
79 | (ly * bhh - ly * -bhh + bh * -bhh) / bh + lh / 2
80 | )
81 |
82 | SimulationBody(
83 | width = lw.toSimulationSize(),
84 | height = lh.toSimulationSize(),
85 | shape = shape.toSimulationBodyShape(coordinates.size),
86 | initialOffset = positionFromCenter.toSimulationVector2(),
87 | bodyConfig = bodyConfig,
88 | ) to positionFromCenter
89 | } ?: throw IllegalStateException()
90 |
91 | fun convertTouchEvent(touchEvent: LayoutTouchEvent) = SimulationTouchEvent(
92 | pointerId = touchEvent.pointerId,
93 | offset = touchEvent.offset.toSimulationVector2(),
94 | type = touchEvent.type
95 | )
96 |
97 | fun convertBorder(size: IntSize, shape: Shape?) = SimulationBorder(
98 | width = size.width.toSimulationSize(),
99 | height = size.height.toSimulationSize(),
100 | shape = shape.toSimulationBorderShape(size)
101 | )
102 |
103 | private fun Int.toSimulationSize() = this / scale
104 |
105 | private fun Float.toSimulationSize() = this / scale
106 |
107 | private fun Offset.toSimulationVector2() = Vector2(x.toDouble(), y.toDouble()).divide(scale)
108 |
109 | private fun List.toVector2() = map {
110 | it.toSimulationVector2()
111 | }
112 |
113 | private fun Shape.toSimulationBodyShape(size: IntSize) = when {
114 | !isSupported() -> throw IllegalArgumentException("${this::class.simpleName} is not supported")
115 | isCircle() -> SimulationShape.Circle(size.width.toSimulationSize() / 2)
116 | isRectangle() -> SimulationShape.Rectangle(
117 | size.width.toSimulationSize(),
118 | size.height.toSimulationSize()
119 | )
120 |
121 | isRoundedCornerRectangle() -> SimulationShape.RoundedCornerRectangle(
122 | width = size.width.toSimulationSize(),
123 | height = size.height.toSimulationSize(),
124 | cornerRadius = (this as RoundedCornerShape).toRadius(
125 | size.width.toFloat(),
126 | size.height.toFloat(),
127 | density
128 | ).toSimulationSize()
129 | )
130 |
131 | else -> SimulationShape.Generic(
132 | toPoints(
133 | Size(size.width.toFloat(), size.height.toFloat()),
134 | LayoutDirection.Ltr,
135 | density,
136 | PATH_SEGMENTS
137 | ).toVector2()
138 | )
139 | }
140 |
141 | private fun Shape?.toSimulationBorderShape(size: IntSize) = when {
142 | this == null -> null
143 | !isSupported() -> throw IllegalArgumentException("${this::class.simpleName} is not supported")
144 | isCircle() -> SimulationShape.Circle(size.width.toSimulationSize() / 2)
145 | isRectangle() -> SimulationShape.Rectangle(
146 | size.width.toSimulationSize(),
147 | size.height.toSimulationSize()
148 | )
149 |
150 | else -> SimulationShape.Generic(
151 | toPoints(
152 | Size(size.width.toFloat(), size.height.toFloat()),
153 | LayoutDirection.Ltr,
154 | density,
155 | PATH_SEGMENTS
156 | ).toVector2()
157 | )
158 | }
159 | }
160 |
161 | internal val LocalLayoutToSimulation = staticCompositionLocalOf {
162 | throw IllegalStateException("No LayoutToSimulation provided")
163 | }
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/samples/Grid.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.samples
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.layout.size
13 | import androidx.compose.foundation.layout.systemBarsPadding
14 | import androidx.compose.foundation.shape.CircleShape
15 | import androidx.compose.foundation.shape.CutCornerShape
16 | import androidx.compose.foundation.shape.RoundedCornerShape
17 | import androidx.compose.material3.Button
18 | import androidx.compose.material3.MaterialTheme
19 | import androidx.compose.material3.Slider
20 | import androidx.compose.material3.Surface
21 | import androidx.compose.material3.Text
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.getValue
24 | import androidx.compose.runtime.mutableStateOf
25 | import androidx.compose.runtime.remember
26 | import androidx.compose.runtime.setValue
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.Modifier
29 | import androidx.compose.ui.geometry.Offset
30 | import androidx.compose.ui.graphics.Color
31 | import androidx.compose.ui.graphics.RectangleShape
32 | import androidx.compose.ui.unit.dp
33 | import de.apuri.physicslayout.GravitySensor
34 | import de.apuri.physicslayout.lib.BodyConfig
35 | import de.apuri.physicslayout.lib.PhysicsLayout
36 | import de.apuri.physicslayout.lib.drag.DragConfig
37 | import de.apuri.physicslayout.lib.physicsBody
38 | import de.apuri.physicslayout.lib.simulation.rememberClock
39 | import de.apuri.physicslayout.lib.simulation.rememberSimulation
40 |
41 | val colors = listOf(
42 | Color.Red,
43 | Color.Blue,
44 | Color.Green,
45 | Color.Cyan,
46 | Color.Yellow,
47 | )
48 |
49 | val shapes = listOf(
50 | RectangleShape,
51 | CircleShape,
52 | RoundedCornerShape(64.dp),
53 | CutCornerShape(16.dp),
54 | )
55 |
56 | @Composable
57 | fun GridScreen() {
58 | Surface(
59 | modifier = Modifier.fillMaxSize(),
60 | color = MaterialTheme.colorScheme.background
61 | ) {
62 | var sliderDensity by remember { mutableStateOf(0.5f) }
63 | var sliderFriction by remember { mutableStateOf(0f) }
64 | var sliderRestitution by remember { mutableStateOf(0f) }
65 | var currentBorderIndex by remember { mutableStateOf(0) }
66 |
67 | val clock = rememberClock()
68 | val simulation = rememberSimulation(clock)
69 |
70 | Column(
71 | Modifier.systemBarsPadding()
72 | ) {
73 | Box(
74 | modifier = Modifier
75 | .weight(1f)
76 | ) {
77 | PhysicsLayout(
78 | Modifier
79 | .fillMaxSize()
80 | .background(Color.DarkGray),
81 | shape = shapes[currentBorderIndex],
82 | simulation = simulation
83 | ) {
84 | GravitySensor { (x, y) ->
85 | simulation.setGravity(Offset(-x, y).times(3f))
86 | }
87 | Row(
88 | Modifier.fillMaxSize()
89 | ) {
90 | repeat(10) { col ->
91 | Column(
92 | Modifier
93 | .fillMaxHeight()
94 | .weight(1f),
95 | verticalArrangement = Arrangement.Center
96 | ) {
97 | repeat(7) { row ->
98 | Box(
99 | Modifier
100 | .weight(1f)
101 | .fillMaxWidth(),
102 | contentAlignment = Alignment.Center
103 | ) {
104 | val bodyConfig = BodyConfig(
105 | density = sliderDensity,
106 | friction = sliderFriction,
107 | restitution = sliderRestitution,
108 | )
109 | Ball("$col$row", bodyConfig = bodyConfig)
110 | }
111 | }
112 | }
113 | }
114 | }
115 | }
116 | }
117 | Column(
118 | modifier = Modifier.weight(0.5f)
119 | ) {
120 | Row(
121 | Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
122 | verticalAlignment = Alignment.CenterVertically
123 | ) {
124 | Text(
125 | text = "Density",
126 | Modifier
127 | .padding(end = 16.dp)
128 | .weight(1f)
129 | )
130 | Slider(value = sliderDensity, onValueChange = {
131 | sliderDensity = it
132 | }, Modifier.weight(3f), valueRange = 0f..1f)
133 | }
134 | Row(
135 | Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
136 | verticalAlignment = Alignment.CenterVertically
137 | ) {
138 | Text(
139 | text = "Friction",
140 | Modifier
141 | .padding(end = 16.dp)
142 | .weight(1f)
143 | )
144 | Slider(
145 | value = sliderFriction,
146 | onValueChange = { sliderFriction = it },
147 | Modifier.weight(3f),
148 | valueRange = 0f..1f
149 | )
150 | }
151 | Row(
152 | Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
153 | verticalAlignment = Alignment.CenterVertically
154 | ) {
155 | Text(
156 | text = "Restitution",
157 | Modifier
158 | .padding(end = 16.dp)
159 | .weight(1f)
160 | )
161 | Slider(
162 | value = sliderRestitution,
163 | onValueChange = { sliderRestitution = it },
164 | Modifier.weight(3f),
165 | valueRange = 0f..1f
166 | )
167 | }
168 | Row {
169 | Button(onClick = { clock.pause() }) {
170 | Text(text = "Pause")
171 | }
172 | Button(onClick = { clock.resume() }) {
173 | Text(text = "Resume")
174 | }
175 | Button(onClick = { currentBorderIndex = (++currentBorderIndex).mod(shapes.size) }) {
176 | Text(text = "Toggle border")
177 | }
178 | }
179 | }
180 | }
181 | }
182 | }
183 |
184 | @Composable
185 | fun Ball(
186 | id: String,
187 | color: Color = Color(0xFFF44336),
188 | bodyConfig: BodyConfig
189 | ) {
190 | Box(
191 | modifier = Modifier
192 | .physicsBody(id = id, shape = CircleShape, bodyConfig = bodyConfig, DragConfig())
193 | .size(32.dp)
194 | .background(color, CircleShape)
195 | )
196 | }
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/samples/Shapes.kt:
--------------------------------------------------------------------------------
1 | package de.apuri.physicslayout.samples
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.border
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.BoxScope
9 | import androidx.compose.foundation.layout.Column
10 | import androidx.compose.foundation.layout.Row
11 | import androidx.compose.foundation.layout.aspectRatio
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.foundation.shape.CircleShape
16 | import androidx.compose.foundation.shape.CutCornerShape
17 | import androidx.compose.foundation.shape.RoundedCornerShape
18 | import androidx.compose.material.icons.Icons
19 | import androidx.compose.material.icons.filled.Favorite
20 | import androidx.compose.material.icons.filled.ThumbUp
21 | import androidx.compose.material3.Icon
22 | import androidx.compose.material3.MaterialTheme
23 | import androidx.compose.material3.Surface
24 | import androidx.compose.runtime.Composable
25 | import androidx.compose.runtime.getValue
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.draw.clip
32 | import androidx.compose.ui.draw.scale
33 | import androidx.compose.ui.geometry.Offset
34 | import androidx.compose.ui.graphics.Brush
35 | import androidx.compose.ui.graphics.Color
36 | import androidx.compose.ui.graphics.RectangleShape
37 | import androidx.compose.ui.graphics.Shape
38 | import androidx.compose.ui.graphics.vector.ImageVector
39 | import androidx.compose.ui.res.painterResource
40 | import androidx.compose.ui.unit.dp
41 | import de.apuri.physicslayout.GravitySensor
42 | import de.apuri.physicslayout.R
43 | import de.apuri.physicslayout.lib.drag.DragConfig
44 | import de.apuri.physicslayout.lib.PhysicsLayout
45 | import de.apuri.physicslayout.lib.physicsBody
46 | import de.apuri.physicslayout.lib.simulation.Clock
47 | import de.apuri.physicslayout.lib.simulation.rememberClock
48 | import de.apuri.physicslayout.lib.simulation.rememberSimulation
49 |
50 | @Composable
51 | fun ShapesScreen() {
52 | Surface(
53 | modifier = Modifier.fillMaxSize(),
54 | color = MaterialTheme.colorScheme.background
55 | ) {
56 | var gravity by remember { mutableStateOf(Offset.Zero) }
57 | val clock = rememberClock()
58 | GravitySensor { (x, y) ->
59 | gravity = Offset(-x, y).times(3f)
60 | }
61 | Column(
62 | verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
63 | ) {
64 |
65 | Row(
66 | modifier = Modifier.padding(horizontal = 12.dp),
67 | horizontalArrangement = Arrangement.spacedBy(12.dp)
68 | ) {
69 | Box(
70 | modifier = Modifier
71 | .weight(1f)
72 | .aspectRatio(1f)
73 | ) {
74 | PhysicsInstance({ gravity }, RectangleShape, clock)
75 | }
76 | Box(
77 | modifier = Modifier
78 | .weight(1f)
79 | .aspectRatio(1f)
80 | ) {
81 | PhysicsInstance({ gravity }, CircleShape, clock)
82 | }
83 | }
84 |
85 | Row(
86 | modifier = Modifier.padding(horizontal = 12.dp),
87 | horizontalArrangement = Arrangement.spacedBy(12.dp)
88 | ) {
89 | Box(
90 | modifier = Modifier
91 | .weight(1f)
92 | .aspectRatio(1f)
93 | ) {
94 | PhysicsInstance({ gravity }, RoundedCornerShape(24.dp), clock)
95 | }
96 | Box(
97 | modifier = Modifier
98 | .weight(1f)
99 | .aspectRatio(1f)
100 | ) {
101 | PhysicsInstance({ gravity }, CutCornerShape(20), clock)
102 | }
103 | }
104 | }
105 | }
106 | }
107 |
108 | @Composable
109 | fun PhysicsInstance(
110 | provideGravity: () -> Offset,
111 | shape: Shape,
112 | clock: Clock
113 | ) {
114 | val simulation = rememberSimulation(clock)
115 | simulation.setGravity(provideGravity())
116 | PhysicsLayout(
117 | modifier = Modifier
118 | .fillMaxSize()
119 | .border(1.dp, MaterialTheme.colorScheme.surfaceVariant, shape)
120 | .clip(shape),
121 | simulation = simulation,
122 | shape = shape,
123 | ) {
124 | Ball(
125 | shape = CircleShape, ball = BallMeta(
126 | borderColors = listOf(
127 | Color(0xFF10B981),
128 | Color(0xFF34D399),
129 | ),
130 | containerColors = listOf(
131 | Color(0xFF059669),
132 | Color(0xFF059669),
133 | ),
134 | icon = Icons.Filled.ThumbUp
135 | )
136 | )
137 | Ball(
138 | shape = RectangleShape, ball = BallMeta(
139 | borderColors = listOf(
140 | Color(0xFFEC4899),
141 | Color(0xFFF472B6),
142 | ),
143 | containerColors = listOf(
144 | Color(0xFFDB2777),
145 | Color(0xFFDB2777),
146 | ),
147 | icon = Icons.Filled.Favorite
148 | )
149 | )
150 |
151 | Ball(
152 | shape = CutCornerShape(33), ball = BallMeta(
153 | borderColors = listOf(
154 | Color(0xFFF59E0B),
155 | Color(0xFFFBBF24),
156 | ),
157 | containerColors = listOf(
158 | Color(0xFFD97706),
159 | Color(0xFFD97706),
160 | ),
161 | iconRes = R.drawable.baseline_emoji_events_24
162 | )
163 | )
164 | Ball(
165 | shape = RoundedCornerShape(25), ball = BallMeta(
166 | borderColors = listOf(
167 | Color(0xFF38BDF8),
168 | Color(0xFF0EA5E9),
169 | ),
170 | containerColors = listOf(
171 | Color(0xFF0284C7),
172 | Color(0xFF0284C7),
173 | ),
174 | iconRes = R.drawable.baseline_cruelty_free_24
175 | )
176 | )
177 | }
178 | }
179 |
180 | @Composable
181 | private fun BoxScope.Ball(shape: Shape, ball: BallMeta) {
182 | Box(
183 | modifier = Modifier
184 | .physicsBody(
185 | shape = shape,
186 | dragConfig = DragConfig(
187 | maxForce = 100.0
188 | ),
189 | )
190 | .align(Alignment.Center)
191 | .size(36.dp)
192 | .background(Brush.verticalGradient(ball.containerColors), shape)
193 | .border(BorderStroke(2.dp, Brush.verticalGradient(ball.borderColors)), shape),
194 | contentAlignment = Alignment.Center
195 | ) {
196 | if (ball.icon != null) {
197 | Icon(modifier = Modifier.scale(0.7f), imageVector = ball.icon, contentDescription = "", tint = Color.White)
198 | } else if (ball.iconRes != null) {
199 | Icon(modifier = Modifier.scale(0.7f), painter = painterResource(id = ball.iconRes), contentDescription = "", tint = Color.White)
200 | }
201 | }
202 | }
203 |
204 | internal data class BallMeta(
205 | val borderColors: List,
206 | val containerColors: List,
207 | val icon: ImageVector? = null,
208 | val iconRes: Int? = null
209 | )
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/samples/Tabs.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalFoundationApi::class)
2 |
3 | package de.apuri.physicslayout.samples
4 |
5 | import android.util.Log
6 | import androidx.compose.animation.core.Spring
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.spring
9 | import androidx.compose.foundation.ExperimentalFoundationApi
10 | import androidx.compose.foundation.background
11 | import androidx.compose.foundation.clickable
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.Column
14 | import androidx.compose.foundation.layout.Row
15 | import androidx.compose.foundation.layout.RowScope
16 | import androidx.compose.foundation.layout.fillMaxSize
17 | import androidx.compose.foundation.layout.fillMaxWidth
18 | import androidx.compose.foundation.layout.height
19 | import androidx.compose.foundation.layout.navigationBarsPadding
20 | import androidx.compose.foundation.layout.offset
21 | import androidx.compose.foundation.layout.padding
22 | import androidx.compose.foundation.layout.wrapContentHeight
23 | import androidx.compose.foundation.pager.HorizontalPager
24 | import androidx.compose.foundation.pager.PagerState
25 | import androidx.compose.foundation.pager.rememberPagerState
26 | import androidx.compose.foundation.rememberScrollState
27 | import androidx.compose.foundation.shape.RoundedCornerShape
28 | import androidx.compose.foundation.verticalScroll
29 | import androidx.compose.material3.MaterialTheme
30 | import androidx.compose.material3.Surface
31 | import androidx.compose.material3.Text
32 | import androidx.compose.runtime.Composable
33 | import androidx.compose.runtime.LaunchedEffect
34 | import androidx.compose.runtime.getValue
35 | import androidx.compose.runtime.mutableStateOf
36 | import androidx.compose.runtime.remember
37 | import androidx.compose.runtime.rememberCoroutineScope
38 | import androidx.compose.runtime.setValue
39 | import androidx.compose.ui.Alignment
40 | import androidx.compose.ui.Modifier
41 | import androidx.compose.ui.draw.clip
42 | import androidx.compose.ui.geometry.Offset
43 | import androidx.compose.ui.graphics.Color
44 | import androidx.compose.ui.graphics.graphicsLayer
45 | import androidx.compose.ui.unit.DpOffset
46 | import androidx.compose.ui.unit.IntOffset
47 | import androidx.compose.ui.unit.dp
48 | import de.apuri.physicslayout.GravitySensor
49 | import de.apuri.physicslayout.lib.PhysicsLayout
50 | import de.apuri.physicslayout.lib.simulation.Simulation
51 | import de.apuri.physicslayout.lib.drag.DragConfig
52 | import de.apuri.physicslayout.lib.physicsBody
53 | import de.apuri.physicslayout.lib.simulation.rememberSimulation
54 | import kotlinx.coroutines.delay
55 | import kotlinx.coroutines.launch
56 | import kotlin.math.absoluteValue
57 |
58 | @Composable
59 | fun FlyingTabsScreen() {
60 | var shakeOffset by remember { mutableStateOf(DpOffset.Zero) }
61 | val simulation = rememberSimulation()
62 | Surface(modifier = Modifier
63 | .fillMaxSize()
64 | .offset {
65 | density.run {
66 | IntOffset(
67 | shakeOffset.x
68 | .toPx()
69 | .toInt(),
70 | shakeOffset.y
71 | .toPx()
72 | .toInt(),
73 | )
74 | }
75 | }, color = MaterialTheme.colorScheme.background) {
76 | val pagerState = rememberPagerState()
77 | val tabs = listOf(
78 | "One",
79 | "Two",
80 | "Three",
81 | )
82 | Column {
83 | Tabs(
84 | modifier = Modifier
85 | .height(250.dp)
86 | .fillMaxWidth()
87 | .background(Color(0xFF10B981)),
88 | items = tabs,
89 | pagerState = pagerState,
90 | simulation = simulation,
91 | )
92 | HorizontalPager(
93 | modifier = Modifier
94 | .weight(1f)
95 | .fillMaxWidth()
96 | .background(MaterialTheme.colorScheme.surface)
97 | .navigationBarsPadding(),
98 | state = pagerState,
99 | pageCount = tabs.size,
100 | beyondBoundsPageCount = 2
101 | ) {
102 | Box(
103 | Modifier
104 | .fillMaxSize()
105 | ) {
106 | val scrollState = rememberScrollState()
107 | val canScrollForward = scrollState.canScrollForward
108 | LaunchedEffect(key1 = canScrollForward) {
109 | if (!canScrollForward) {
110 | shakeOffset = DpOffset(0.dp, -20.dp)
111 | delay(50)
112 | shakeOffset = DpOffset.Zero
113 | }
114 | }
115 | Log.d("bbb", "CAN $canScrollForward")
116 | Column(
117 | Modifier.verticalScroll(scrollState)
118 | ) {
119 | repeat(50) {
120 | androidx.compose.material3.ListItem(headlineContent = { Text(text = "Lorem Ipsum $it") }, supportingContent = { Text(text = "Dolor") })
121 | }
122 | }
123 | }
124 | }
125 | }
126 | }
127 | }
128 |
129 | @Composable
130 | fun Tabs(
131 | modifier: Modifier = Modifier,
132 | items: List,
133 | pagerState: PagerState,
134 | simulation: Simulation
135 | ) {
136 | val scope = rememberCoroutineScope()
137 | GravitySensor { (x, y) ->
138 | simulation.setGravity(Offset(-x, y).times(3f))
139 | }
140 | PhysicsLayout(
141 | modifier = modifier,
142 | simulation = simulation,
143 | ) {
144 | val currentPage = pagerState.currentPage
145 | val currentPageOffset = pagerState.currentPageOffsetFraction
146 |
147 | Row(
148 | modifier.wrapContentHeight(align = Alignment.Bottom)
149 | ) {
150 | items.forEachIndexed { index, label ->
151 | val state = when {
152 | currentPage == index -> TabState.Selected
153 | currentPage - index == 1 && currentPageOffset < 0 -> TabState.Approaching(currentPageOffset.absoluteValue)
154 | currentPage - index == -1 && currentPageOffset > 0 -> TabState.Approaching(currentPageOffset.absoluteValue)
155 | else -> TabState.Deselected
156 | }
157 |
158 | Tab(label = label, state) {
159 | scope.launch {
160 | pagerState.animateScrollToPage(index)
161 | }
162 | }
163 | }
164 | }
165 | }
166 | }
167 |
168 | @Composable
169 | fun RowScope.Tab(
170 | label: String,
171 | state: TabState,
172 | onClick: () -> Unit
173 | ) {
174 | Box(
175 | Modifier
176 | .physicsBody(dragConfig = DragConfig(), shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
177 | .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
178 | .weight(1f),
179 | contentAlignment = Alignment.Center
180 | ) {
181 | val targetValue = when (state) {
182 | is TabState.Approaching -> state.value * 0.7f
183 | TabState.Deselected -> 0f
184 | TabState.Selected -> 1f
185 | }
186 |
187 | val animSpec = when (state) {
188 | is TabState.Approaching -> spring(stiffness = Spring.StiffnessHigh)
189 | TabState.Deselected -> spring(stiffness = Spring.StiffnessHigh)
190 | TabState.Selected -> spring(stiffness = 6_000f, dampingRatio = 0.3f)
191 | }
192 |
193 | if (label == "One") {
194 | Log.d("asdf", targetValue.toString())
195 | }
196 |
197 | val progress by animateFloatAsState(
198 | targetValue = targetValue,
199 | animationSpec = animSpec
200 | )
201 |
202 | Box(
203 | modifier = Modifier
204 | .fillMaxWidth()
205 | .background(Color.Black.copy(alpha = 0.12f), shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
206 | .clickable { onClick() },
207 | contentAlignment = Alignment.Center,
208 | ) {
209 | Text(
210 | modifier = Modifier
211 | .graphicsLayer {
212 | translationY = -size.height / 3f * progress
213 | alpha = 0.5f + 0.5f * (1 - progress)
214 | }
215 | .padding(vertical = 12.dp),
216 | text = label,
217 | style = MaterialTheme.typography.titleMedium,
218 | color = MaterialTheme.colorScheme.surface
219 | )
220 | }
221 |
222 | Box(
223 | modifier = Modifier
224 | .fillMaxWidth()
225 | .graphicsLayer {
226 | translationY = size.height + 1 - size.height * progress
227 | val overshoot = java.lang.Float.max(0f, progress - 1f)
228 | scaleY = java.lang.Float.max(1f, 1f + overshoot * 2f)
229 | }
230 | .background(
231 | MaterialTheme.colorScheme.surface,
232 | shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
233 | ),
234 | contentAlignment = Alignment.Center,
235 | ) {
236 | Text(
237 | modifier = Modifier.padding(vertical = 12.dp),
238 | text = label,
239 | style = MaterialTheme.typography.titleMedium,
240 | color = Color(0xFF10B981)
241 | )
242 | }
243 | }
244 | }
245 |
246 | sealed interface TabState {
247 | object Selected: TabState
248 | object Deselected: TabState
249 | data class Approaching(val value: Float): TabState
250 | }
--------------------------------------------------------------------------------
/app/src/main/java/de/apuri/physicslayout/samples/StarLauncher.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalMaterial3Api::class)
2 |
3 | package de.apuri.physicslayout.samples
4 |
5 | import androidx.compose.animation.AnimatedContent
6 | import androidx.compose.animation.ExperimentalAnimationApi
7 | import androidx.compose.animation.SizeTransform
8 | import androidx.compose.animation.fadeIn
9 | import androidx.compose.animation.fadeOut
10 | import androidx.compose.animation.slideInVertically
11 | import androidx.compose.animation.slideOutVertically
12 | import androidx.compose.animation.with
13 | import androidx.compose.foundation.gestures.awaitFirstDown
14 | import androidx.compose.foundation.gestures.detectTapGestures
15 | import androidx.compose.foundation.layout.Arrangement
16 | import androidx.compose.foundation.layout.Box
17 | import androidx.compose.foundation.layout.BoxScope
18 | import androidx.compose.foundation.layout.Column
19 | import androidx.compose.foundation.layout.Row
20 | import androidx.compose.foundation.layout.fillMaxSize
21 | import androidx.compose.foundation.layout.padding
22 | import androidx.compose.foundation.layout.size
23 | import androidx.compose.foundation.layout.systemBarsPadding
24 | import androidx.compose.foundation.layout.wrapContentWidth
25 | import androidx.compose.foundation.shape.CircleShape
26 | import androidx.compose.foundation.shape.RoundedCornerShape
27 | import androidx.compose.material.icons.Icons
28 | import androidx.compose.material.icons.filled.Add
29 | import androidx.compose.material.icons.filled.Star
30 | import androidx.compose.material3.Card
31 | import androidx.compose.material3.CardDefaults
32 | import androidx.compose.material3.ExperimentalMaterial3Api
33 | import androidx.compose.material3.Icon
34 | import androidx.compose.material3.MaterialTheme
35 | import androidx.compose.material3.Surface
36 | import androidx.compose.material3.Text
37 | import androidx.compose.runtime.Composable
38 | import androidx.compose.runtime.Immutable
39 | import androidx.compose.runtime.derivedStateOf
40 | import androidx.compose.runtime.getValue
41 | import androidx.compose.runtime.key
42 | import androidx.compose.runtime.mutableStateListOf
43 | import androidx.compose.runtime.mutableStateOf
44 | import androidx.compose.runtime.remember
45 | import androidx.compose.runtime.rememberCoroutineScope
46 | import androidx.compose.runtime.setValue
47 | import androidx.compose.ui.Alignment
48 | import androidx.compose.ui.Modifier
49 | import androidx.compose.ui.geometry.Offset
50 | import androidx.compose.ui.graphics.Color
51 | import androidx.compose.ui.input.pointer.pointerInput
52 | import androidx.compose.ui.unit.dp
53 | import de.apuri.physicslayout.GravitySensor
54 | import de.apuri.physicslayout.lib.BodyConfig
55 | import de.apuri.physicslayout.lib.PhysicsLayout
56 | import de.apuri.physicslayout.lib.drag.DragConfig
57 | import de.apuri.physicslayout.lib.physicsBody
58 | import de.apuri.physicslayout.lib.simulation.rememberSimulation
59 | import kotlinx.coroutines.delay
60 | import kotlinx.coroutines.launch
61 |
62 | @Composable
63 | fun StarLauncherScreen() {
64 | Surface(
65 | modifier = Modifier.fillMaxSize(),
66 | color = MaterialTheme.colorScheme.background
67 | ) {
68 | val simulation = rememberSimulation()
69 | var starCounter by remember { mutableStateOf(0) }
70 | val stars = remember { mutableStateListOf() }
71 |
72 | val redCount = remember {
73 | derivedStateOf { stars.count { it.color == red } }
74 | }
75 |
76 | val purpleCount = remember {
77 | derivedStateOf { stars.count { it.color == purple } }
78 | }
79 |
80 | val blueCount = remember {
81 | derivedStateOf { stars.count { it.color == blue } }
82 | }
83 |
84 | val greenCount = remember {
85 | derivedStateOf { stars.count { it.color == green } }
86 | }
87 |
88 | GravitySensor { (x, y) ->
89 | simulation.setGravity(Offset(-x, y).times(3f))
90 | }
91 |
92 | PhysicsLayout(
93 | modifier = Modifier.systemBarsPadding(),
94 | simulation = simulation,
95 | shape = RoundedCornerShape(64.dp)
96 | ) {
97 | stars.forEach { starMeta ->
98 | key(starMeta.id) {
99 | Star(
100 | id = starMeta.id,
101 | color = starMeta.color,
102 | ) { id ->
103 | stars.removeIf { it.id == id }
104 | }
105 | }
106 | }
107 |
108 | StarCounterContainer(
109 | { redCount.value },
110 | { purpleCount.value },
111 | { blueCount.value },
112 | { greenCount.value },
113 | )
114 |
115 | Column(
116 | Modifier
117 | .fillMaxSize()
118 | .padding(bottom = 32.dp),
119 | horizontalAlignment = Alignment.CenterHorizontally,
120 | verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.Bottom)
121 | ) {
122 | StarLauncher(
123 | color = blue,
124 | ) {
125 | stars.add(StarMeta("star-${starCounter++}", blue))
126 | }
127 |
128 | Row(
129 | horizontalArrangement = Arrangement.spacedBy(64.dp)
130 | ) {
131 | StarLauncher(
132 | color = red,
133 | ) {
134 | stars.add(StarMeta("star-${starCounter++}", red))
135 | }
136 |
137 | StarLauncher(
138 | color = purple,
139 | ) {
140 | stars.add(StarMeta("star-${starCounter++}", purple))
141 | }
142 | }
143 |
144 | StarLauncher(
145 | color = green,
146 | ) {
147 | stars.add(StarMeta("star-${starCounter++}", green))
148 | }
149 | }
150 | }
151 |
152 | }
153 | }
154 |
155 | @Composable
156 | fun StarLauncher(
157 | color: Color,
158 | onStar: () -> Unit
159 | ) {
160 | val scope = rememberCoroutineScope()
161 | Card(
162 | modifier = Modifier
163 | .physicsBody(
164 | shape = CircleShape,
165 | bodyConfig = BodyConfig(isStatic = true)
166 | )
167 | .pointerInput(Unit) {
168 | detectTapGestures(
169 | onPress = {
170 | val job = scope.launch {
171 | while (true) {
172 | onStar()
173 | delay(100)
174 | }
175 | }
176 | tryAwaitRelease()
177 | job.cancel()
178 | }
179 | )
180 | },
181 | shape = CircleShape,
182 | colors = CardDefaults.cardColors(containerColor = color)
183 | ) {
184 | Box(
185 | modifier = Modifier.size(64.dp)
186 | ) {
187 | Icon(
188 | modifier = Modifier.align(Alignment.Center),
189 | imageVector = Icons.Default.Add,
190 | contentDescription = "Add red"
191 | )
192 | }
193 | }
194 | }
195 |
196 | @Composable
197 | fun BoxScope.StarCounterContainer(
198 | provideRedCount: () -> Int,
199 | providePurpleCount: () -> Int,
200 | provideBlueCount: () -> Int,
201 | provideGreenCount: () -> Int,
202 | ) {
203 | var dragConfig by remember { mutableStateOf(null) }
204 |
205 | Card(
206 | modifier = Modifier
207 | .align(Alignment.Center)
208 | .physicsBody(
209 | shape = RoundedCornerShape(16.dp),
210 | bodyConfig = BodyConfig(isStatic = dragConfig == null),
211 | dragConfig = dragConfig,
212 | )
213 | .pointerInput(Unit) {
214 | awaitPointerEventScope {
215 | awaitFirstDown()
216 | dragConfig = DragConfig()
217 | }
218 | },
219 | shape = RoundedCornerShape(16.dp),
220 | ) {
221 | Row(
222 | Modifier
223 | .padding(16.dp)
224 | .wrapContentWidth(),
225 | horizontalArrangement = Arrangement.spacedBy(16.dp)
226 | ) {
227 | StarCounter(color = red, provideCount = provideRedCount)
228 | StarCounter(color = purple, provideCount = providePurpleCount)
229 | StarCounter(color = blue, provideCount = provideBlueCount)
230 | StarCounter(color = green, provideCount = provideGreenCount)
231 | }
232 | }
233 | }
234 |
235 | @OptIn(ExperimentalAnimationApi::class)
236 | @Composable
237 | fun StarCounter(color: Color, provideCount: () -> Int) {
238 | Column(
239 | horizontalAlignment = Alignment.CenterHorizontally
240 | ) {
241 | Card(
242 | modifier = Modifier,
243 | shape = CircleShape,
244 | colors = CardDefaults.cardColors(containerColor = color)
245 | ) {
246 | Icon(
247 | modifier = Modifier
248 | .size(32.dp)
249 | .padding(4.dp),
250 | imageVector = Icons.Default.Star,
251 | contentDescription = "",
252 | tint = Color.White
253 | )
254 | }
255 |
256 | AnimatedContent(
257 | targetState = provideCount(),
258 | transitionSpec = {
259 | if (targetState > initialState) {
260 | slideInVertically { height -> height / 3 } + fadeIn() with
261 | slideOutVertically { height -> -height / 3 } + fadeOut()
262 | } else {
263 | slideInVertically { height -> -height / 3 } + fadeIn() with
264 | slideOutVertically { height -> height / 3 } + fadeOut()
265 | }.using(
266 | SizeTransform(clip = false)
267 | )
268 | }
269 | ) {
270 | Text(
271 | modifier = Modifier.padding(top = 8.dp),
272 | text = "$it",
273 | style = MaterialTheme.typography.titleMedium
274 | )
275 | }
276 | }
277 | }
278 |
279 | @Composable
280 | fun BoxScope.Star(
281 | id: String,
282 | color: Color,
283 | onClick: (String) -> Unit
284 | ) {
285 | Box(
286 | Modifier
287 | .align(Alignment.TopCenter)
288 | .padding(top = 32.dp)
289 | ) {
290 | Card(
291 | modifier = Modifier
292 | .physicsBody(
293 | id = id,
294 | shape = CircleShape,
295 | dragConfig = DragConfig()
296 | ),
297 | shape = CircleShape,
298 | colors = CardDefaults.cardColors(containerColor = color),
299 | onClick = { onClick(id) }
300 | ) {
301 | Icon(
302 | modifier = Modifier
303 | .size(48.dp)
304 | .padding(4.dp),
305 | imageVector = Icons.Default.Star,
306 | contentDescription = "",
307 | tint = Color.White
308 | )
309 | }
310 | }
311 |
312 | }
313 |
314 | @Immutable
315 | data class StarMeta(
316 | val id: String,
317 | val color: Color,
318 | )
319 |
320 | private val red = Color(0xFFEF5350)
321 | private val purple = Color(0xFFAB47BC)
322 | private val blue = Color(0xFF42A5F5)
323 | private val green = Color(0xFF66BB6A)
--------------------------------------------------------------------------------