├── .gitignore ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml └── vcs.xml ├── README.md ├── build.gradle ├── filebrowser ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── zachklipp │ │ └── filebrowser │ │ ├── App.kt │ │ ├── FileGrid.kt │ │ ├── FileSystemBrowser.kt │ │ ├── MainActivity.kt │ │ ├── TreeBrowser.kt │ │ └── ui │ │ └── theme │ │ ├── Color.kt │ │ ├── Shape.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── fractalnav ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── zachklipp │ └── fractalnav │ ├── FakeMovableContent.kt │ ├── FractalChild.kt │ ├── FractalNavHost.kt │ ├── FractalNavScope.kt │ ├── FractalNavStateImpl.kt │ ├── Lerp.kt │ ├── ScaleConstraintsModifier.kt │ └── WorkaroundBoxOnPlacedBugModifier.kt ├── galaxyapp ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── zachklipp │ │ └── galaxyapp │ │ └── BoxOnPlacedRepro.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── zachklipp │ │ └── galaxyapp │ │ ├── App.kt │ │ ├── BackButton.kt │ │ ├── Components.kt │ │ ├── GalaxyItem.kt │ │ ├── ImageLoading.kt │ │ ├── MainActivity.kt │ │ ├── PlanetItem.kt │ │ ├── PlanetarySystem.kt │ │ ├── RadialLayout.kt │ │ ├── Sample.kt │ │ ├── StarItem.kt │ │ ├── Universe.kt │ │ ├── UniverseInfo.kt │ │ └── ui │ │ └── theme │ │ ├── Color.kt │ │ ├── Shape.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.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 | /.idea/misc.xml 11 | /.idea/gradle.xml 12 | .DS_Store 13 | /build 14 | /captures 15 | .externalNativeBuild 16 | .cxx 17 | local.properties 18 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | fractal-nav -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # compose-fractal-nav 2 | 3 | A proof-of-concept of a fractal/recursive navigation system. 4 | 5 | Instead of defining a bunch of top-level routes with navigation moving laterally between them, you define your navigation recursively. You can think of it like showing your entire composable UI at once, where certain parts can be zoomed-into. It's hard to explain and I'm writing this README after 3 straight days of hacking on my vacation, so easier to just show you. This is the app in this repo: 6 | 7 | https://user-images.githubusercontent.com/101754/161682976-66d138a0-b8b9-4fde-a3f6-059294a5225b.mp4 8 | 9 | ## API 10 | 11 | Like most navigation libraries, this one starts with a wrapper composable that defines the navigable area. It's called `FractalNavHost`. It provides a `FractalNavScope` to its content composable. Inside that block, you can define "zoomable" children with the `FractalNavChild` composable. Each child is identified with a string key, and you can zoom into a child by calling `zoomToChild()`. 12 | 13 | The content block of a `FractalNavChild` gets access to a few properties that describe whether or not it's zoomed in, how far it's zoomed, and a function that zooms it back out to its parent. 14 | 15 | Children can also define their own children, recursing as deep as you like. The library will only compose what's necessary to show the active child. Everything between the `FractalNavHost` and the `FractalNavChild` will be removed from the composition – even when multiple children are nested. When a child zooms back out, its parent content is composed again, with any state from `rememberSaveable`s restored. 16 | 17 | For more details, take a look at [`FractalNavScope` and `FractalNavChildScope`](galaxyapp/src/main/java/com/zachklipp/fractalnav/FractalNavScope.kt). 18 | 19 | ## API sample 20 | 21 | Let's build this simple app: 22 | 23 | https://user-images.githubusercontent.com/101754/161687429-9aabfd7d-c722-48c3-a979-f25690a1684a.mp4 24 | 25 | Here's the code: 26 | 27 | ```kotlin 28 | // The host should wrap the root of your app. 29 | FractalNavHost(Modifier.fillMaxSize()) { 30 | Row(Modifier.wrapContentSize()) { 31 | Text("Click ") 32 | Link("here") { 33 | Text( 34 | "42", 35 | // Scale the text in when clicked. 36 | modifier = Modifier.scaleByZoomFactor(), 37 | style = MaterialTheme.typography.h1, 38 | maxLines = 1 39 | ) 40 | } 41 | Text(" to learn more.") 42 | } 43 | } 44 | ``` 45 | 46 | And here's the `Link` function: 47 | 48 | ```kotlin 49 | @Composable 50 | fun FractalNavScope.Link( 51 | text: String, 52 | content: @Composable FractalNavChildScope.() -> Unit 53 | ) { 54 | // This creates some content that can be zoomed into. 55 | FractalNavChild( 56 | // It's identified by a string key… 57 | key = "link", 58 | modifier = Modifier.clickable { 59 | // …which can be used to expand its content. This will animate 60 | // the content block below to take up the full screen and also 61 | // zoom the parent content, that called this composable, out of 62 | // view. 63 | zoomToChild("link") 64 | } 65 | ) { 66 | Box(contentAlignment = Alignment.Center) { 67 | Text(text, Modifier.graphicsLayer { 68 | // The zoomFactor property is available inside the FractalNavChild 69 | // block. It starts at 0, then when zoomToChild is called it will 70 | // be animated up to 1. In this case, we want this text to start 71 | // at the full size and shrink when zoomed in. 72 | alpha = 1f - zoomFactor 73 | }) 74 | 75 | // The isActive flag is also provided inside the FractalNavChild 76 | // content block, and means `zoomFactor > 0` – but is backed by 77 | // a derivedStateOf so it won't invalidate more than once during 78 | // the zoom animation. 79 | if (isActive) { 80 | content() 81 | BackHandler { 82 | // This will animate zoomFactor back down to 0. 83 | zoomToParent() 84 | } 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | compose_ui_version = '1.2.0-alpha07' 4 | } 5 | }// Top-level build file where you can add configuration options common to all sub-projects/modules. 6 | plugins { 7 | id 'com.android.application' version '7.3.0-alpha08' apply false 8 | id 'com.android.library' version '7.3.0-alpha08' apply false 9 | id 'org.jetbrains.kotlin.android' version '1.6.10' apply false 10 | } -------------------------------------------------------------------------------- /filebrowser/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /filebrowser/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace 'com.zachklipp.filebrowser' 8 | compileSdk 32 9 | 10 | defaultConfig { 11 | applicationId "com.zachklipp.filebrowser" 12 | minSdk 24 13 | targetSdk 32 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 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | } 36 | buildFeatures { 37 | compose true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion '1.1.1' 41 | } 42 | packagingOptions { 43 | resources { 44 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 45 | } 46 | } 47 | } 48 | 49 | dependencies { 50 | implementation project(':fractalnav') 51 | implementation 'io.coil-kt:coil-compose:2.0.0-rc02' 52 | implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.0' 53 | implementation 'androidx.core:core-ktx:1.7.0' 54 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' 55 | implementation 'androidx.activity:activity-compose:1.4.0' 56 | implementation "androidx.compose.ui:ui:$compose_ui_version" 57 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" 58 | implementation 'androidx.compose.material:material:1.1.1' 59 | implementation(platform("com.squareup.okio:okio-bom:3.0.0")) 60 | implementation("com.squareup.okio:okio") 61 | implementation("com.squareup.okio:okio-fakefilesystem") 62 | testImplementation 'junit:junit:4.13.2' 63 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 64 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 65 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" 66 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" 67 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" 68 | } -------------------------------------------------------------------------------- /filebrowser/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 -------------------------------------------------------------------------------- /filebrowser/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/App.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.Surface 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import okio.FileSystem 9 | import okio.Path 10 | import okio.Path.Companion.toPath 11 | import okio.fakefilesystem.FakeFileSystem 12 | 13 | @Composable 14 | fun App( 15 | rootPath: Path, 16 | fileSystem: FileSystem, 17 | navState: TreeBrowserState = remember { TreeBrowserState() } 18 | ) { 19 | MaterialTheme { 20 | Surface { 21 | FileSystemBrowser(rootPath, fileSystem, state = navState) 22 | } 23 | } 24 | } 25 | 26 | @Preview 27 | @Composable 28 | private fun AppPreview() { 29 | App( 30 | rootPath = "/".toPath(), 31 | fileSystem = FakeFileSystem(), 32 | ) 33 | } -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/FileGrid.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.layout.Arrangement.spacedBy 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.verticalScroll 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import kotlin.math.ceil 11 | 12 | @Composable 13 | fun FileGrid( 14 | count: Int, 15 | modifier: Modifier = Modifier, 16 | content: @Composable (index: Int) -> Unit 17 | ) { 18 | Column( 19 | modifier = modifier 20 | .verticalScroll(rememberScrollState()) 21 | .padding(8.dp), 22 | verticalArrangement = spacedBy(8.dp) 23 | ) { 24 | repeat(ceil(count / 2f).toInt()) { i -> 25 | Row(horizontalArrangement = spacedBy(8.dp)) { 26 | Box(Modifier.weight(1f), propagateMinConstraints = true) { 27 | content(i * 2) 28 | } 29 | Box(Modifier.weight(1f), propagateMinConstraints = true) { 30 | if (i * 2 + 1 < count) { 31 | content(i * 2 + 1) 32 | } else { 33 | Spacer(Modifier) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | // Lazy columns also crash 41 | // LazyColumn( 42 | // modifier = modifier, 43 | // contentPadding = PaddingValues(8.dp), 44 | // verticalArrangement = spacedBy(8.dp) 45 | // ) { 46 | // items(ceil(count / 2f).toInt()) { i -> 47 | // Row(horizontalArrangement = spacedBy(8.dp)) { 48 | // Box(Modifier.weight(1f), propagateMinConstraints = true) { 49 | // content(i * 2) 50 | // } 51 | // Box(Modifier.weight(1f), propagateMinConstraints = true) { 52 | // if (i * 2 + 1 < count) { 53 | // content(i * 2 + 1) 54 | // } else { 55 | // Spacer(Modifier) 56 | // } 57 | // } 58 | // } 59 | // } 60 | // } 61 | 62 | // Lazy grids crash when nested. 63 | // LazyVerticalGrid( 64 | // cells = GridCells.Adaptive(150.dp), 65 | // verticalArrangement = Arrangement.spacedBy(8.dp), 66 | // horizontalArrangement = Arrangement.spacedBy(8.dp), 67 | // modifier = Modifier 68 | // .weight(1f) 69 | // .padding(8.dp) 70 | // ) { 71 | // items(childCount) { i -> 72 | // ChildNode(node = children?.getOrNull(i)) 73 | // } 74 | // } 75 | } -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/FileSystemBrowser.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser 2 | 3 | import android.widget.Toast 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.Crossfade 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.layout.Arrangement.spacedBy 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Email 12 | import androidx.compose.material.icons.filled.Warning 13 | import androidx.compose.runtime.* 14 | import androidx.compose.runtime.saveable.rememberSaveable 15 | import androidx.compose.ui.Alignment.Companion.CenterHorizontally 16 | import androidx.compose.ui.Alignment.Companion.CenterVertically 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.platform.LocalContext 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.unit.sp 22 | import com.zachklipp.fractalnav.ZoomDirection.ZoomingOut 23 | import com.zachklipp.fractalnav.lerp 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.async 26 | import kotlinx.coroutines.supervisorScope 27 | import kotlinx.coroutines.withContext 28 | import okio.FileMetadata 29 | import okio.FileSystem 30 | import okio.Path 31 | 32 | @Composable 33 | fun FileSystemBrowser( 34 | root: Path, 35 | fileSystem: FileSystem, 36 | modifier: Modifier = Modifier, 37 | state: TreeBrowserState = remember { TreeBrowserState() } 38 | ) { 39 | TreeBrowser( 40 | rootNode = root, 41 | modifier = modifier, 42 | state = state, 43 | nodeKey = { it.toString() }, 44 | childrenContent = { path -> 45 | FileList(path, fileSystem) 46 | }, 47 | nodeContent = { path, onClick, thumbnail -> 48 | FileCard(path, onClick, thumbnail) 49 | }, 50 | thumbnail = { path -> 51 | fileThumbnailType(path, fileSystem) 52 | }, 53 | ) 54 | } 55 | 56 | @OptIn(ExperimentalMaterialApi::class) 57 | @Composable 58 | private fun FileCard( 59 | path: Path?, 60 | onClick: (() -> Unit)?, 61 | thumbnail: @Composable (Modifier) -> Unit 62 | ) { 63 | val context = LocalContext.current 64 | Card(onClick = onClick ?: { 65 | Toast.makeText(context, "File clicked: $path", Toast.LENGTH_SHORT).show() 66 | }) { 67 | Row( 68 | horizontalArrangement = spacedBy(8.dp), 69 | verticalAlignment = CenterVertically 70 | ) { 71 | thumbnail(Modifier.size(64.dp)) 72 | Text( 73 | path?.name.orEmpty(), 74 | Modifier 75 | .weight(1f) 76 | .then(if (path == null) Modifier.background(Color.LightGray) else Modifier) 77 | ) 78 | } 79 | } 80 | } 81 | 82 | private val LoadingThumbnail = ThumbnailType.Leaf { 83 | CircularProgressIndicator( 84 | Modifier 85 | .fillMaxSize() 86 | .wrapContentSize() 87 | ) 88 | } 89 | private val ErrorThumbnail = ThumbnailType.Leaf { 90 | Icon(Icons.Default.Warning, contentDescription = "error loading thumbnail") 91 | } 92 | 93 | @Composable 94 | private fun fileThumbnailType(path: Path?, fileSystem: FileSystem): ThumbnailType { 95 | return produceState(LoadingThumbnail, path) { 96 | if (path == null) return@produceState 97 | withContext(Dispatchers.IO) { 98 | supervisorScope { 99 | val metadata = async { fileSystem.metadata(path) } 100 | val children = async { fileSystem.list(path) } 101 | val result = runCatching { Pair(metadata.await(), children.await()) }.getOrNull() 102 | 103 | value = if (result == null) { 104 | ErrorThumbnail 105 | } else if (result.second.isEmpty()) { 106 | ThumbnailType.Leaf { 107 | FilePreview(path, result.first, fileSystem) 108 | } 109 | } else { 110 | ThumbnailType.Parent 111 | } 112 | } 113 | } 114 | }.value 115 | } 116 | 117 | @Composable 118 | private fun TreeBrowserScope.FileList( 119 | path: Path?, 120 | fileSystem: FileSystem, 121 | initialChildren: List? = null, 122 | ) { 123 | var childCount by rememberSaveable { mutableStateOf(initialChildren?.size ?: -1) } 124 | var children: List? by rememberSaveable { mutableStateOf(initialChildren) } 125 | LaunchedEffect(path) { 126 | if (path == null) return@LaunchedEffect 127 | withContext(Dispatchers.IO) { 128 | children = fileSystem.list(path).also { 129 | childCount = it.size 130 | } 131 | } 132 | } 133 | val isLoaded by remember { derivedStateOf { childCount > -1 } } 134 | 135 | Crossfade( 136 | isLoaded, 137 | Modifier 138 | .fillMaxSize() 139 | .wrapContentSize() 140 | ) { isLoaded -> 141 | if (!isLoaded) { 142 | CircularProgressIndicator() 143 | } else { 144 | Column( 145 | modifier = Modifier 146 | .fillMaxSize() 147 | .wrapContentSize(), 148 | horizontalAlignment = CenterHorizontally 149 | ) { 150 | val showContent = isActive && zoomDirection != ZoomingOut 151 | Row( 152 | modifier = Modifier.fillMaxWidth(), 153 | horizontalArrangement = Arrangement.Center 154 | ) { 155 | Text( 156 | childCount.toString(), 157 | fontSize = lerp(24f, LocalTextStyle.current.fontSize.value, zoomFactor).sp 158 | ) 159 | AnimatedVisibility(showContent) { 160 | Text(" files") 161 | } 162 | } 163 | if (isActive && childCount > 0) { 164 | FileGrid( 165 | childCount, 166 | Modifier 167 | .scaleByZoomFactor() 168 | .scaleLayoutByZoomFactor() 169 | .fillExpandedWidth() 170 | .fillMaxHeight() 171 | ) { 172 | ChildNode(children?.getOrNull(it)) 173 | } 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | @Composable 181 | private fun FilePreview(path: Path, metadata: FileMetadata, fileSystem: FileSystem) { 182 | Icon( 183 | Icons.Default.Email, 184 | contentDescription = null, 185 | modifier = Modifier 186 | .fillMaxSize() 187 | .wrapContentSize() 188 | ) 189 | } -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import com.zachklipp.fractalnav.FractalNavState 7 | import com.zachklipp.filebrowser.ui.theme.FileBrowserTheme 8 | import okio.FileSystem 9 | import okio.Path.Companion.toOkioPath 10 | 11 | class MainActivity : ComponentActivity() { 12 | 13 | private var navState: TreeBrowserState? = null 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | 18 | @Suppress("DEPRECATION") 19 | val navState = navState ?: run { 20 | (lastCustomNonConfigurationInstance as? TreeBrowserState) ?: TreeBrowserState() 21 | }.also { navState = it } 22 | 23 | val root = this.dataDir.toOkioPath() 24 | 25 | setContent { 26 | FileBrowserTheme { 27 | // A surface container using the 'background' color from the theme 28 | App(root, FileSystem.SYSTEM, navState) 29 | } 30 | } 31 | } 32 | 33 | @Suppress("OVERRIDE_DEPRECATION") 34 | override fun onRetainCustomNonConfigurationInstance(): Any? = navState 35 | } 36 | -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/TreeBrowser.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser 2 | 3 | import androidx.activity.compose.BackHandler 4 | import androidx.compose.animation.Crossfade 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.wrapContentSize 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import com.zachklipp.fractalnav.* 11 | import com.zachklipp.fractalnav.ZoomDirection.ZoomingIn 12 | 13 | @JvmInline 14 | value class TreeBrowserState private constructor( 15 | internal val navState: FractalNavState 16 | ) { 17 | constructor() : this(FractalNavState()) 18 | } 19 | 20 | interface TreeBrowserScope : FractalNavChildScope { 21 | @Composable 22 | fun ChildNode(node: N) 23 | } 24 | 25 | sealed interface ThumbnailType { 26 | data class Leaf(val content: @Composable () -> Unit) : ThumbnailType 27 | object Parent : ThumbnailType 28 | } 29 | 30 | @Composable 31 | fun TreeBrowser( 32 | rootNode: N, 33 | nodeKey: (N) -> String, 34 | childrenContent: @Composable TreeBrowserScope.(N) -> Unit, 35 | nodeContent: @Composable ( 36 | node: N, 37 | onClick: (() -> Unit)?, 38 | thumbnail: @Composable (Modifier) -> Unit 39 | ) -> Unit, 40 | thumbnail: @Composable (N) -> ThumbnailType, 41 | modifier: Modifier = Modifier, 42 | state: TreeBrowserState = remember { TreeBrowserState() } 43 | ) { 44 | FractalNavHost(modifier = modifier, state = state.navState) { 45 | ParentNode(rootNode, nodeKey, childrenContent, nodeContent, thumbnail) 46 | } 47 | } 48 | 49 | @Composable 50 | private fun FractalNavScope.ParentNode( 51 | node: N, 52 | nodeKey: (N) -> String, 53 | childrenContent: @Composable TreeBrowserScope.(N) -> Unit, 54 | nodeContent: @Composable ( 55 | node: N, 56 | onClick: (() -> Unit)?, 57 | thumbnail: @Composable (Modifier) -> Unit 58 | ) -> Unit, 59 | thumbnail: @Composable (N) -> ThumbnailType, 60 | ) { 61 | val scope = object : TreeBrowserScope, FractalNavChildScope by fakeChildScope(this) { 62 | @Composable 63 | override fun ChildNode(node: N) { 64 | val thumbnailType = thumbnail(node) 65 | nodeContent(node, onClick = when (thumbnailType) { 66 | is ThumbnailType.Leaf -> null 67 | ThumbnailType.Parent -> { 68 | { zoomToChild(nodeKey(node)) } 69 | } 70 | }) { modifier -> 71 | Crossfade(thumbnailType as ThumbnailType, modifier 72 | .fillMaxSize() 73 | .wrapContentSize()) { thumbnailType -> 74 | when (thumbnailType) { 75 | ThumbnailType.Parent -> { 76 | FractalNavChild( 77 | key = nodeKey(node), 78 | modifier = Modifier 79 | ) { 80 | val childScope = this 81 | childScope.ParentNode( 82 | node, 83 | nodeKey, 84 | childrenContent, 85 | nodeContent, 86 | thumbnail 87 | ) 88 | if (isFullyZoomedIn || zoomDirection == ZoomingIn) { 89 | BackHandler { 90 | zoomToParent() 91 | } 92 | } 93 | } 94 | } 95 | is ThumbnailType.Leaf -> { 96 | thumbnailType.content() 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | scope.childrenContent(node) 104 | } 105 | 106 | private fun fakeChildScope(fractalNavScope: FractalNavScope): FractalNavChildScope { 107 | return (fractalNavScope as? FractalNavChildScope) 108 | ?: object : FractalNavChildScope, 109 | FractalNavScope by fractalNavScope { 110 | override val isActive: Boolean 111 | get() = true 112 | override val isFullyZoomedIn: Boolean 113 | get() = true 114 | override val zoomFactor: Float 115 | get() = 1f 116 | override val zoomDirection: ZoomDirection? 117 | get() = null 118 | 119 | override fun zoomToParent() {} 120 | override fun Modifier.fillExpandedWidth(): Modifier = this 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser.ui.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.darkColors 5 | import androidx.compose.runtime.Composable 6 | 7 | private val DarkColorPalette = darkColors( 8 | primary = Purple200, 9 | primaryVariant = Purple700, 10 | secondary = Teal200 11 | ) 12 | 13 | @Composable 14 | fun FileBrowserTheme(content: @Composable () -> Unit) { 15 | MaterialTheme( 16 | colors = DarkColorPalette, 17 | typography = Typography, 18 | shapes = Shapes, 19 | content = content 20 | ) 21 | } -------------------------------------------------------------------------------- /filebrowser/src/main/java/com/zachklipp/filebrowser/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.filebrowser.ui.theme 2 | 3 | import androidx.compose.material.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 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /filebrowser/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /filebrowser/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 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/filebrowser/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /filebrowser/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | fractal-nav 3 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /filebrowser/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /fractalnav/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /fractalnav/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace 'com.zachklipp.fractalnav' 8 | compileSdk 32 9 | 10 | defaultConfig { 11 | minSdk 21 12 | targetSdk 32 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | vectorDrawables { 18 | useSupportLibrary true 19 | } 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | kotlinOptions { 33 | jvmTarget = '1.8' 34 | } 35 | buildFeatures { 36 | compose true 37 | } 38 | composeOptions { 39 | kotlinCompilerExtensionVersion '1.1.1' 40 | } 41 | packagingOptions { 42 | resources { 43 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 44 | } 45 | } 46 | } 47 | 48 | dependencies { 49 | implementation "androidx.compose.foundation:foundation:$compose_ui_version" 50 | implementation "androidx.compose.ui:ui:$compose_ui_version" 51 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" 52 | testImplementation 'junit:junit:4.13.2' 53 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 54 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 55 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" 56 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" 57 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" 58 | } -------------------------------------------------------------------------------- /fractalnav/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 -------------------------------------------------------------------------------- /fractalnav/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/FakeMovableContent.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | // TODO replace with real movableContentOf when fixed. Currently it crashes when zooming out. 6 | //fun

movableContentOf(content: @Composable (P) -> Unit): @Composable (P) -> Unit { 7 | // return content 8 | //} -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/FractalChild.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.animation.core.AnimationSpec 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.relocation.BringIntoViewRequester 6 | import androidx.compose.runtime.* 7 | import androidx.compose.runtime.snapshots.Snapshot 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.layout.LayoutCoordinates 10 | import androidx.compose.ui.layout.layout 11 | import androidx.compose.ui.unit.IntOffset 12 | import androidx.compose.ui.unit.constrainHeight 13 | import androidx.compose.ui.unit.constrainWidth 14 | 15 | internal interface FractalParent : FractalNavScope { 16 | val activeChild: FractalChild? 17 | val zoomDirection: ZoomDirection? 18 | val zoomAnimationSpecFactory: () -> AnimationSpec 19 | val viewportWidth: Int 20 | 21 | fun zoomOut() 22 | } 23 | 24 | @OptIn(ExperimentalFoundationApi::class) 25 | internal class FractalChild( 26 | val key: String, 27 | private val parent: FractalParent 28 | ) { 29 | private val childState = FractalNavState() as FractalNavStateImpl 30 | private val childScope = ChildScope(childState) 31 | private var _content: (@Composable FractalNavChildScope.() -> Unit)? by mutableStateOf(null) 32 | var placeholderCoordinates: LayoutCoordinates? by mutableStateOf(null) 33 | val bringIntoViewRequester = BringIntoViewRequester() 34 | 35 | /** 36 | * A [movableContentOf] wrapper that allows all state inside the child's composable to be moved 37 | * from being composed from inside the content of a [FractalNavHost] to being the root of 38 | * the host. 39 | */ 40 | private val movableContent = movableContentOf { modifier: Modifier -> 41 | FractalNavHost( 42 | state = childState, 43 | modifier = modifier, 44 | zoomAnimationSpecFactory = { parent.zoomAnimationSpecFactory() } 45 | ) { 46 | _content?.invoke(childScope) 47 | } 48 | } 49 | 50 | fun setContent(content: @Composable FractalNavChildScope.() -> Unit) { 51 | if (Snapshot.withoutReadObservation { _content == null }) { 52 | _content = content 53 | } 54 | } 55 | 56 | @Suppress("NOTHING_TO_INLINE") 57 | @Composable 58 | inline fun MovableContent(modifier: Modifier = Modifier) { 59 | movableContent(modifier) 60 | } 61 | 62 | private inner class ChildScope(private val state: FractalNavStateImpl) : 63 | FractalNavScope by state, 64 | FractalNavChildScope { 65 | override val isActive: Boolean by derivedStateOf { 66 | parent.activeChild === this@FractalChild 67 | } 68 | override val isFullyZoomedIn: Boolean by derivedStateOf { 69 | isActive && parent.childZoomFactor == 1f 70 | } 71 | override val zoomFactor: Float by derivedStateOf { 72 | if (isActive) parent.childZoomFactor else 0f 73 | } 74 | override val zoomDirection: ZoomDirection? 75 | get() = if (isActive) parent.zoomDirection else null 76 | 77 | override fun zoomToChild(key: String) { 78 | // An inactive child should never have zoomed-in children, so if this child isn't 79 | // already zoomed in we need to start zooming. 80 | parent.zoomToChild(this@FractalChild.key) 81 | state.zoomToChild(key) 82 | } 83 | 84 | override fun zoomToParent() { 85 | if (state.hasActiveChild) { 86 | // A zoomed-out child should never have zoomed-in descendents. 87 | state.zoomOut() 88 | } 89 | parent.zoomOut() 90 | } 91 | 92 | override fun Modifier.fillExpandedWidth(): Modifier = layout { measurable, constraints -> 93 | val parentWidth = parent.viewportWidth 94 | val parentWidthConstraints = constraints.copy( 95 | minWidth = parentWidth, 96 | maxWidth = parentWidth 97 | ) 98 | val placeable = measurable.measure(parentWidthConstraints) 99 | layout( 100 | width = constraints.constrainWidth(placeable.width), 101 | height = constraints.constrainHeight(placeable.height) 102 | ) { 103 | placeable.place(IntOffset.Zero) 104 | } 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/FractalNavHost.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.animation.core.AnimationSpec 4 | import androidx.compose.animation.core.tween 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.runtime.* 7 | import androidx.compose.runtime.saveable.rememberSaveableStateHolder 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.layout.onPlaced 10 | 11 | /** 12 | * A container that can host special composables defined with [FractalNavScope.FractalNavChild] that 13 | * can be [zoomed][FractalNavScope.zoomToChild] into and replace the content of this host. 14 | * 15 | * To preserve navigation state across configuration changes, create your own [FractalNavState] and 16 | * store it in a retained configuration instance (e.g. an AAC `ViewModel`). 17 | */ 18 | @NonRestartableComposable 19 | @Composable 20 | fun FractalNavHost( 21 | modifier: Modifier = Modifier, 22 | state: FractalNavState = remember { FractalNavState() }, 23 | zoomAnimationSpec: AnimationSpec = FractalNavState.DefaultZoomAnimationSpec, 24 | content: @Composable FractalNavScope.() -> Unit 25 | ) { 26 | FractalNavHost( 27 | state = state, 28 | modifier = modifier, 29 | zoomAnimationSpecFactory = { zoomAnimationSpec }, 30 | content = content 31 | ) 32 | } 33 | 34 | /** 35 | * Stores the navigation state for a [FractalNavHost]. 36 | * Should only be passed to a single [FractalNavHost] at a time. 37 | */ 38 | sealed interface FractalNavState { 39 | companion object { 40 | val DefaultZoomAnimationSpec: AnimationSpec = tween(1_000) 41 | } 42 | } 43 | 44 | fun FractalNavState(): FractalNavState = FractalNavStateImpl() 45 | 46 | @Composable 47 | internal fun FractalNavHost( 48 | state: FractalNavState, 49 | modifier: Modifier, 50 | zoomAnimationSpecFactory: () -> AnimationSpec, 51 | content: @Composable FractalNavScope.() -> Unit 52 | ) { 53 | state as FractalNavStateImpl 54 | val coroutineScope = rememberCoroutineScope() 55 | SideEffect { 56 | state.coroutineScope = coroutineScope 57 | state.zoomAnimationSpecFactory = zoomAnimationSpecFactory 58 | } 59 | 60 | val contentStateHolder = rememberSaveableStateHolder() 61 | 62 | // This box serves two purposes: 63 | // 1. It defines what the viewport coordinates are, and gives us a place to put the modifier 64 | // to read them that won't become detached in the middle of zoom animations. 65 | // 2. The layout behavior of Box ensures that the active child will be drawn over the content. 66 | Box( 67 | modifier = modifier 68 | .onPlaced { state.viewportCoordinates = it } 69 | .workaroundBoxOnPlacedBug(), 70 | propagateMinConstraints = true 71 | ) { 72 | if (state.composeContent) { 73 | contentStateHolder.SaveableStateProvider("fractal-nav-host") { 74 | Box( 75 | modifier = Modifier 76 | .then(state.contentZoomModifier) 77 | .onPlaced { state.scaledContentCoordinates = it } 78 | .workaroundBoxOnPlacedBug(), 79 | propagateMinConstraints = true 80 | ) { 81 | content(state) 82 | } 83 | } 84 | } 85 | 86 | state.activeChild?.MovableContent( 87 | modifier = if (state.isFullyZoomedIn) Modifier else state.childZoomModifier 88 | ) 89 | } 90 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/FractalNavScope.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.graphics.graphicsLayer 6 | 7 | enum class ZoomDirection { 8 | ZoomingIn, 9 | ZoomingOut 10 | } 11 | 12 | interface FractalNavScope { 13 | /** 14 | * True when a child of this scope is being zoomed in or has fully-zoomed in. 15 | * Always false initially. 16 | */ 17 | val hasActiveChild: Boolean 18 | 19 | /** 20 | * When [hasActiveChild] is true, this is the zoom factor of the active child. 21 | */ 22 | val childZoomFactor: Float 23 | 24 | /** 25 | * Defines a child composable that can be zoomed into by calling [zoomToChild]. 26 | * The [content] of this function is automatically placed inside its own [FractalNavHost], and 27 | * so can define its own children. 28 | */ 29 | @Composable 30 | fun FractalNavChild( 31 | key: String, 32 | modifier: Modifier, 33 | content: @Composable FractalNavChildScope.() -> Unit 34 | ) 35 | 36 | /** 37 | * Requests that the [FractalNavChild] passed the given [key] be activated and eventually 38 | * entirely replace the content of this [FractalNavHost]. 39 | */ 40 | fun zoomToChild(key: String) 41 | } 42 | 43 | /** 44 | * A [FractalNavScope] that is also a child of a [FractalNavChild]. 45 | */ 46 | interface FractalNavChildScope : FractalNavScope { 47 | /** 48 | * True if this [FractalNavChild] is being zoomed in or out of, or is fully zoomed in. 49 | * Only one sibling may be active at a time. 50 | * When this is false, [isFullyZoomedIn] will always be false. 51 | */ 52 | val isActive: Boolean 53 | 54 | /** 55 | * True if this [FractalNavChild] is completely replacing its parent content. 56 | * When this is true, [isActive] will always be true. 57 | */ 58 | val isFullyZoomedIn: Boolean 59 | 60 | /** 61 | * The amount that this child is zoomed, between 0 ([isActive]=false) and 62 | * 1 ([isFullyZoomedIn]=true). 63 | */ 64 | val zoomFactor: Float 65 | 66 | /** Null when not zooming. */ 67 | val zoomDirection: ZoomDirection? 68 | 69 | /** Requests the parent of this [FractalNavChild] zoom it out and eventually deactivate it. */ 70 | fun zoomToParent() 71 | 72 | fun Modifier.scaleLayoutByZoomFactor(): Modifier = scaleLayout { zoomFactor } 73 | fun Modifier.scaleByZoomFactor(): Modifier = graphicsLayer { 74 | scaleX = zoomFactor 75 | scaleY = zoomFactor 76 | } 77 | 78 | fun Modifier.alphaByZoomFactor(): Modifier = graphicsLayer { alpha = zoomFactor } 79 | 80 | /** 81 | * Measures the modified element with the width constraints it will have when fully zoomed-in. 82 | */ 83 | fun Modifier.fillExpandedWidth(): Modifier 84 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/FractalNavStateImpl.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.AnimationSpec 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.foundation.relocation.bringIntoViewRequester 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.composed 12 | import androidx.compose.ui.geometry.Offset 13 | import androidx.compose.ui.geometry.lerp 14 | import androidx.compose.ui.graphics.BlurEffect 15 | import androidx.compose.ui.graphics.TileMode 16 | import androidx.compose.ui.graphics.TransformOrigin 17 | import androidx.compose.ui.graphics.graphicsLayer 18 | import androidx.compose.ui.layout.LayoutCoordinates 19 | import androidx.compose.ui.layout.layout 20 | import androidx.compose.ui.layout.onPlaced 21 | import androidx.compose.ui.platform.LocalDensity 22 | import androidx.compose.ui.unit.* 23 | import com.zachklipp.fractalnav.ZoomDirection.ZoomingOut 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.launch 26 | import kotlin.math.roundToInt 27 | 28 | internal class FractalNavStateImpl : FractalNavState, FractalNavScope, FractalParent { 29 | // These do not need to be backed by snapshot state because they're set in a side effect. 30 | lateinit var coroutineScope: CoroutineScope 31 | override lateinit var zoomAnimationSpecFactory: () -> AnimationSpec 32 | 33 | private val children = mutableMapOf() 34 | private val zoomFactorAnimatable = Animatable(0f) 35 | private val zoomFactor: Float by zoomFactorAnimatable.asState() 36 | val isFullyZoomedIn by derivedStateOf { zoomFactor == 1f } 37 | override var zoomDirection: ZoomDirection? by mutableStateOf(null) 38 | private set 39 | 40 | /** 41 | * When true, the nav hosts's content should be composed. This is false when a child is fully 42 | * zoomed-in AND is not imminently starting to zoom out. We compose when starting to zoom out 43 | * even before the [zoomFactor] animation has started to give the content a chance to initialize 44 | * before starting the animation, to avoid jank caused by an extra long first-frame. 45 | */ 46 | val composeContent: Boolean get() = !isFullyZoomedIn || zoomDirection == ZoomingOut 47 | 48 | var viewportCoordinates: LayoutCoordinates? = null 49 | var scaledContentCoordinates: LayoutCoordinates? by mutableStateOf(null) 50 | 51 | override val viewportWidth: Int 52 | get() = viewportCoordinates?.size?.width ?: 0 53 | 54 | override val hasActiveChild: Boolean get() = activeChild != null 55 | override val childZoomFactor: Float get() = zoomFactor 56 | 57 | /** 58 | * The first time the content, and thus this modifier, is composed after 59 | * starting a zoom-out, this modifier will run before the placeholder has been 60 | * placed, so we can't calculate the scale. We'll return early, and because 61 | * the above layer will have set alpha to 0, it doesn't matter that the content 62 | * is initially in the wrong place/size. The subsequent frame it will be able to 63 | * calculate correctly. 64 | */ 65 | private var isContentBeingScaled by mutableStateOf(false) 66 | 67 | /** 68 | * The child that is currently either zoomed in or out, or fully zoomed in. Null when fully 69 | * zoomed out. 70 | */ 71 | override var activeChild: FractalChild? by mutableStateOf(null) 72 | private var childPlaceholderSize: IntSize? by mutableStateOf(null) 73 | 74 | /** 75 | * A pair of values representing the x and y scale factors that the content needs to be scaled 76 | * by to make the placeholder fill the screen. 77 | */ 78 | private val activeChildScaleTarget: Offset 79 | get() { 80 | val coords = viewportCoordinates?.takeIf { it.isAttached } ?: return Offset(1f, 1f) 81 | val childSize = childPlaceholderSize ?: return Offset(1f, 1f) 82 | return Offset( 83 | x = coords.size.width / childSize.width.toFloat(), 84 | y = coords.size.height / childSize.height.toFloat() 85 | ) 86 | } 87 | 88 | val contentZoomModifier 89 | get() = if (activeChild != null) { 90 | Modifier 91 | .graphicsLayer { 92 | // Somehow, even though this modifier should immediately be removed when 93 | // activeChild is set to null, it's still running this block so we can just exit 94 | // early in that case. Relatedly, it seems that if we *don't* return before 95 | // reading zoomFactor, the snapshot system will sometimes throw a 96 | // ConcurrentModificationException. 97 | if (activeChild == null) return@graphicsLayer 98 | 99 | // The scale needs to happen around the center of the placeholder. 100 | val coords = scaledContentCoordinates?.takeIf { it.isAttached } 101 | val childCoords = activeChild?.placeholderCoordinates?.takeIf { it.isAttached } 102 | val childBounds = childCoords?.let { 103 | coords?.localBoundingBoxOf(childCoords, clipBounds = false) 104 | } 105 | 106 | if (coords == null || childBounds == null) { 107 | // If there's an active child but the content's not being scaled it means 108 | // we're on the first frame of a zoom-out and the scale couldn't be 109 | // calculated yet, so the content is in the wrong place, and we shouldn't 110 | // draw it. 111 | alpha = 0f 112 | isContentBeingScaled = false 113 | return@graphicsLayer 114 | } 115 | isContentBeingScaled = true 116 | 117 | val scaleTarget = activeChildScaleTarget 118 | scaleX = lerp(1f, scaleTarget.x, zoomFactor) 119 | scaleY = lerp(1f, scaleTarget.y, zoomFactor) 120 | 121 | val pivot = TransformOrigin( 122 | pivotFractionX = childBounds.center.x / (coords.size.width), 123 | pivotFractionY = childBounds.center.y / (coords.size.height) 124 | ) 125 | transformOrigin = pivot 126 | 127 | // And we need to translate so that the left edge of the placeholder will be 128 | // eventually moved to the left edge of the viewport. 129 | val parentCenter = viewportCoordinates!!.size.center.toOffset() 130 | val distanceToCenter = parentCenter - childBounds.center 131 | translationX = zoomFactor * distanceToCenter.x 132 | translationY = zoomFactor * distanceToCenter.y 133 | 134 | // Fade the content out so that if it's drawing its own background it won't 135 | // blink in and out when the content enters/leaves the composition. 136 | alpha = 1f - zoomFactor 137 | 138 | // Radius of 0f causes a crash. 139 | renderEffect = BlurEffect( 140 | // Swap the x and y scale values for the blur radius so the blur scales 141 | // squarely, and not proportionally with the rest of the layer. 142 | radiusX = 0.000001f + (zoomFactor * scaleTarget.y), 143 | radiusY = 0.000001f + (zoomFactor * scaleTarget.x), 144 | // Since this layer is also being clipped, decal gives a better look for 145 | // the gradient near the edges than the default. 146 | edgeTreatment = TileMode.Decal 147 | ) 148 | } 149 | .composed { 150 | // When the scale modifier stops being applied, it's not longer scaling. 151 | DisposableEffect(this@FractalNavStateImpl) { 152 | onDispose { 153 | isContentBeingScaled = false 154 | } 155 | } 156 | Modifier 157 | } 158 | } else Modifier 159 | 160 | /** 161 | * Modifier that is applied to the active child when it's currently being zoomed and has been 162 | * removed from the content composition to be composed by the host instead. 163 | */ 164 | val childZoomModifier = Modifier 165 | .layout { measurable, constraints -> 166 | // But scale the layout bounds up instead, so the child will grow to fill the space 167 | // previously filled by the content. 168 | val childSize = childPlaceholderSize!!.toSize() 169 | val parentSize = viewportCoordinates!!.size.toSize() 170 | val scaledSize = lerp(childSize, parentSize, zoomFactor) 171 | val scaledConstraints = Constraints( 172 | minWidth = scaledSize.width.roundToInt(), 173 | minHeight = scaledSize.height.roundToInt(), 174 | maxWidth = scaledSize.width.roundToInt(), 175 | maxHeight = scaledSize.height.roundToInt() 176 | ) 177 | val placeable = measurable.measure(scaledConstraints) 178 | layout( 179 | constraints.constrainWidth(placeable.width), 180 | constraints.constrainHeight(placeable.height) 181 | ) { 182 | val coords = viewportCoordinates!! 183 | val childCoords = activeChild?.placeholderCoordinates?.takeIf { it.isAttached } 184 | // If the activeChild is null, that means the animation finished _just_ before this 185 | // placement pass – e.g. the user could have been scrolling the content while the 186 | // animation was still running. 187 | val offset = if (childCoords == null || !isContentBeingScaled) { 188 | IntOffset.Zero 189 | } else { 190 | val placeholderBounds = 191 | coords.localBoundingBoxOf(childCoords, clipBounds = false) 192 | placeholderBounds.topLeft.round() 193 | } 194 | placeable.place(offset) 195 | } 196 | } 197 | 198 | @OptIn(ExperimentalFoundationApi::class) 199 | @Composable 200 | override fun FractalNavChild( 201 | key: String, 202 | modifier: Modifier, 203 | content: @Composable FractalNavChildScope.() -> Unit 204 | ) { 205 | key(this, key) { 206 | // TODO This check fails inside LazyVerticalGrid. It's probably too strict anyway. 207 | // check(composeContent) { 208 | // "FractalNavHost content shouldn't be composed when composeContent is false." 209 | // } 210 | 211 | val child = remember { 212 | if (activeChild?.key == key) { 213 | // If there's an active child on the first composition, that means that we're 214 | // starting a zoom-out and should move the child into the content composition 215 | // here by re-using the child's state. 216 | activeChild!! 217 | } else { 218 | FractalChild(key, parent = this) 219 | } 220 | } 221 | 222 | // Register the child with its key so that zoomToChild can find it. 223 | DisposableEffect(child) { 224 | check(key !in children) { 225 | "FractalNavChild with key \"$key\" has already been composed." 226 | } 227 | children[key] = child 228 | onDispose { 229 | // TODO uncomment this once I figure out why the state is being re-initialized. 230 | //child.placeholderCoordinates = null 231 | children -= key 232 | } 233 | } 234 | 235 | child.setContent(content) 236 | 237 | // When the host is composing the content request the placeholder box to stay at the 238 | // size the content measured before the animation started. 239 | val placeholderSizeModifier = if (child === activeChild) { 240 | childPlaceholderSize?.let { size -> 241 | with(LocalDensity.current) { 242 | Modifier.size(DpSize(size.width.toDp(), size.height.toDp())) 243 | } 244 | } ?: Modifier 245 | } else Modifier 246 | 247 | Box( 248 | modifier = modifier 249 | .onPlaced { child.placeholderCoordinates = it } 250 | .workaroundBoxOnPlacedBug() 251 | .bringIntoViewRequester(child.bringIntoViewRequester) 252 | .then(placeholderSizeModifier), 253 | propagateMinConstraints = true 254 | ) { 255 | // The active child is always composed directly by the host, so when that happens 256 | // remove it from the composition here so the movable content is moved and not 257 | // re-instantiated. 258 | if (child !== activeChild) { 259 | child.MovableContent() 260 | } 261 | } 262 | } 263 | } 264 | 265 | @OptIn(ExperimentalFoundationApi::class) 266 | override fun zoomToChild(key: String) { 267 | // Can't zoom into a child while a different child is currently active. 268 | // However, if we're currently zooming out of a child, and that same child requested to 269 | // be zoomed in again, we can cancel the zoom out and start zooming in immediately. 270 | if (activeChild != null && 271 | (activeChild?.key != key || zoomDirection != ZoomingOut) 272 | ) { 273 | return 274 | } 275 | 276 | val requestedChild = children.getOrElse(key) { 277 | throw IllegalArgumentException("No child with key \"$key\".") 278 | } 279 | activeChild = requestedChild 280 | 281 | // Capture the size before starting the animation since the child's layout node will be 282 | // removed from wherever it is but we need the placeholder to preserve that space. 283 | // This could probably be removed once speculative layout exists. 284 | childPlaceholderSize = activeChild?.placeholderCoordinates?.takeIf { it.isAttached }?.size 285 | zoomDirection = ZoomDirection.ZoomingIn 286 | 287 | coroutineScope.launch { 288 | try { 289 | // Try to make the child fully visible before starting zoom so the scale animation 290 | // doesn't end up scaling a clipped view. 291 | requestedChild.bringIntoViewRequester.bringIntoView() 292 | zoomFactorAnimatable.animateTo(1f, zoomAnimationSpecFactory()) 293 | } finally { 294 | // If we weren't cancelled by an opposing animation, jump to the end state. 295 | if (zoomDirection == ZoomDirection.ZoomingIn) { 296 | zoomDirection = null 297 | // Do this last since it can suspend and we want to make sure the other states are 298 | // updated asap. 299 | zoomFactorAnimatable.snapTo(1f) 300 | } 301 | } 302 | } 303 | } 304 | 305 | override fun zoomOut() { 306 | // If already zooming or zoomed out, do nothing. 307 | if (activeChild == null || zoomDirection == ZoomingOut) return 308 | 309 | zoomDirection = ZoomingOut 310 | coroutineScope.launch { 311 | // Composing the parent for the first time can take some time. Wait a frame before 312 | // animating to give it a chance to settle. 313 | withFrameMillis {} 314 | try { 315 | zoomFactorAnimatable.animateTo(0f, zoomAnimationSpecFactory()) 316 | } finally { 317 | // If we weren't cancelled by an opposing animation, jump to the end state. 318 | if (zoomDirection == ZoomingOut) { 319 | activeChild = null 320 | zoomDirection = null 321 | // Do this last since it can suspend and we want to make sure the other states 322 | // are updated asap. 323 | zoomFactorAnimatable.snapTo(0f) 324 | } 325 | } 326 | } 327 | } 328 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/Lerp.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import kotlin.math.roundToInt 4 | 5 | fun lerp(i1: Int, i2: Int, fraction: Float): Int { 6 | return (i1 + (i2 - i1) * fraction).roundToInt() 7 | } 8 | 9 | fun lerp(f1: Float, f2: Float, fraction: Float): Float { 10 | return f1 + (f2 - f1) * fraction 11 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/ScaleConstraintsModifier.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.geometry.Offset 5 | import androidx.compose.ui.graphics.drawscope.scale 6 | import androidx.compose.ui.layout.layout 7 | import androidx.compose.ui.unit.IntOffset 8 | import androidx.compose.ui.unit.round 9 | import kotlin.math.roundToInt 10 | 11 | /** 12 | * Insets and centers a layout by a [factor] of its measured size. 13 | * Does not actually scale the layer, so the modified element may draw outside its bounds. 14 | * Works around the [scale] modifier not being used correctly in calculations. 15 | */ 16 | internal fun Modifier.scaleLayout(factor: () -> Float): Modifier = layout { m, c -> 17 | @Suppress("NAME_SHADOWING") 18 | val scale = factor() 19 | if (scale == 0f) { 20 | // Don't measure or place if it won't take any space anyway. 21 | return@layout layout(0, 0) {} 22 | } 23 | val p = m.measure(c) 24 | val width = p.width * scale 25 | val height = p.height * scale 26 | layout(width.roundToInt(), height.roundToInt()) { 27 | val center = Offset(width, height) / 2f 28 | val pCenter = IntOffset(p.width, p.height) / 2f 29 | p.place(center.round() - pCenter) 30 | } 31 | } -------------------------------------------------------------------------------- /fractalnav/src/main/java/com/zachklipp/fractalnav/WorkaroundBoxOnPlacedBugModifier.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.fractalnav 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.layout.LayoutModifier 5 | import androidx.compose.ui.layout.Measurable 6 | import androidx.compose.ui.layout.MeasureResult 7 | import androidx.compose.ui.layout.MeasureScope 8 | import androidx.compose.ui.unit.Constraints 9 | import androidx.compose.ui.unit.IntOffset 10 | 11 | /** 12 | * Add this modifier after `onPlaced` when applied to a Box. Without it, the onPlaced will never 13 | * get called. Works by simply adding a no-op layout node wrapper between the onPlaced and the 14 | * Box. See b/228128961. 15 | */ 16 | internal fun Modifier.workaroundBoxOnPlacedBug(): Modifier = 17 | this.then(WorkaroundBoxOnPlacedBugModifier) 18 | 19 | private object WorkaroundBoxOnPlacedBugModifier : LayoutModifier { 20 | override fun MeasureScope.measure( 21 | measurable: Measurable, 22 | constraints: Constraints 23 | ): MeasureResult = with(measurable.measure(constraints)) { 24 | layout(width, height) { 25 | place(IntOffset.Zero) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /galaxyapp/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /galaxyapp/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace 'com.zachklipp.galaxyapp' 8 | compileSdk 32 9 | 10 | defaultConfig { 11 | applicationId "com.zachklipp.galaxyapp" 12 | minSdk 21 13 | targetSdk 32 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 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | } 36 | buildFeatures { 37 | compose true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion '1.1.1' 41 | } 42 | packagingOptions { 43 | resources { 44 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 45 | } 46 | } 47 | } 48 | 49 | dependencies { 50 | implementation project(':fractalnav') 51 | implementation 'io.coil-kt:coil-compose:2.0.0-rc02' 52 | implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.0' 53 | implementation 'androidx.core:core-ktx:1.7.0' 54 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' 55 | implementation 'androidx.activity:activity-compose:1.4.0' 56 | implementation "androidx.compose.ui:ui:$compose_ui_version" 57 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" 58 | implementation "androidx.compose.material:material:$compose_ui_version" 59 | testImplementation 'junit:junit:4.13.2' 60 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 61 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 62 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" 63 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" 64 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" 65 | } -------------------------------------------------------------------------------- /galaxyapp/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 -------------------------------------------------------------------------------- /galaxyapp/src/androidTest/java/com/zachklipp/galaxyapp/BoxOnPlacedRepro.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.LayoutCoordinates 6 | import androidx.compose.ui.layout.layout 7 | import androidx.compose.ui.layout.onPlaced 8 | import androidx.compose.ui.test.junit4.createComposeRule 9 | import androidx.compose.ui.unit.IntOffset 10 | import androidx.test.ext.junit.runners.AndroidJUnit4 11 | import org.junit.Assert.assertNotNull 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | 16 | /** 17 | * See b/228128961. 18 | */ 19 | @RunWith(AndroidJUnit4::class) 20 | class BoxOnPlacedRepro { 21 | 22 | @get:Rule 23 | val rule = createComposeRule() 24 | 25 | @Test 26 | fun onBoxPlaced_failing() { 27 | var coordinates: LayoutCoordinates? = null 28 | rule.setContent { 29 | Box(Modifier.onPlaced { coordinates = it }) 30 | } 31 | rule.runOnIdle { 32 | assertNotNull(coordinates) 33 | } 34 | } 35 | 36 | @Test 37 | fun onBoxPlaced_passing() { 38 | var coordinates: LayoutCoordinates? = null 39 | rule.setContent { 40 | Box(Modifier 41 | .onPlaced { coordinates = it } 42 | .layout { m, c -> 43 | with(m.measure(c)) { 44 | layout(width, height) { 45 | place(IntOffset.Zero) 46 | } 47 | } 48 | } 49 | ) 50 | } 51 | rule.runOnIdle { 52 | assertNotNull(coordinates) 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/App.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.Surface 6 | import androidx.compose.material.darkColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.zachklipp.fractalnav.FractalNavHost 12 | import com.zachklipp.fractalnav.FractalNavState 13 | 14 | @Composable 15 | @Preview 16 | fun App(navState: FractalNavState = remember { FractalNavState() }) { 17 | val universeInfo = remember { UniverseInfo() } 18 | MaterialTheme(colors = darkColors()) { 19 | Surface { 20 | FractalNavHost( 21 | state = navState, 22 | modifier = Modifier.fillMaxSize() 23 | ) { 24 | Universe(universeInfo) 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/BackButton.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.activity.compose.BackHandler 4 | import androidx.compose.material.Icon 5 | import androidx.compose.material.IconButton 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.ArrowBack 8 | import androidx.compose.runtime.Composable 9 | import com.zachklipp.fractalnav.FractalNavChildScope 10 | import com.zachklipp.fractalnav.ZoomDirection 11 | 12 | @Composable 13 | fun FractalNavChildScope.BackButton() { 14 | if (isFullyZoomedIn || zoomDirection == ZoomDirection.ZoomingIn) { 15 | BackHandler { 16 | zoomToParent() 17 | } 18 | } 19 | 20 | IconButton(onClick = { zoomToParent() }) { 21 | Icon(Icons.Default.ArrowBack, contentDescription = "Go back") 22 | } 23 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/Components.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.layout.Arrangement.spacedBy 5 | import androidx.compose.foundation.lazy.grid.GridCells 6 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 7 | import androidx.compose.foundation.lazy.grid.items 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.key 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | 15 | @Composable 16 | fun ListHeader(text: String) { 17 | Text( 18 | text, 19 | Modifier 20 | .padding(8.dp) 21 | .fillMaxWidth() 22 | .wrapContentWidth() 23 | ) 24 | } 25 | 26 | @Composable 27 | fun SpaceList( 28 | items: List, 29 | modifier: Modifier = Modifier, 30 | content: @Composable (T) -> Unit 31 | ) { 32 | Column( 33 | modifier = modifier 34 | .padding(8.dp) 35 | .fillMaxSize(), 36 | verticalArrangement = Arrangement.Absolute.spacedBy(8.dp) 37 | ) { 38 | items.forEach { item -> 39 | key(item) { 40 | content(item) 41 | } 42 | } 43 | } 44 | } 45 | 46 | // Note: Seems to crash when zooming out to items on the left side. 47 | @Composable 48 | fun SpaceGrid( 49 | items: List, 50 | modifier: Modifier = Modifier, 51 | content: @Composable (T) -> Unit 52 | ) { 53 | LazyVerticalGrid( 54 | columns = GridCells.Fixed(2), 55 | verticalArrangement = spacedBy(8.dp), 56 | horizontalArrangement = spacedBy(8.dp), 57 | modifier = modifier 58 | .padding(8.dp) 59 | .fillMaxSize(), 60 | ) { 61 | items(items) { item -> 62 | content(item) 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | fun InfoText(text: String, modifier: Modifier = Modifier) { 69 | Text( 70 | text.lineSequence().joinToString(separator = " "), 71 | style = MaterialTheme.typography.body2, 72 | modifier = modifier.padding(horizontal = 8.dp) 73 | ) 74 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/GalaxyItem.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.ExperimentalFoundationApi 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 10 | import androidx.compose.foundation.relocation.BringIntoViewRequester 11 | import androidx.compose.foundation.relocation.bringIntoViewRequester 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.foundation.verticalScroll 15 | import androidx.compose.material.Card 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment.Companion.Center 20 | import androidx.compose.ui.Alignment.Companion.CenterVertically 21 | import androidx.compose.ui.Alignment.Companion.TopCenter 22 | import androidx.compose.ui.Alignment.Companion.TopStart 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.graphics.BlendMode 25 | import androidx.compose.ui.graphics.Shadow 26 | import androidx.compose.ui.graphics.graphicsLayer 27 | import androidx.compose.ui.text.style.TextAlign 28 | import androidx.compose.ui.unit.dp 29 | import com.zachklipp.fractalnav.FractalNavChildScope 30 | import com.zachklipp.fractalnav.FractalNavScope 31 | import com.zachklipp.fractalnav.ZoomDirection.ZoomingIn 32 | import com.zachklipp.fractalnav.ZoomDirection.ZoomingOut 33 | import com.zachklipp.fractalnav.lerp 34 | 35 | private val Galaxy.fractalKey get() = "galaxy-$name" 36 | 37 | @Composable 38 | fun FractalNavScope.GalaxyItem( 39 | galaxy: Galaxy, 40 | universeInfo: UniverseInfo, 41 | modifier: Modifier = Modifier 42 | ) { 43 | Card( 44 | modifier.clickable { zoomToChild(galaxy.fractalKey) } 45 | ) { 46 | Row( 47 | modifier = Modifier.padding(8.dp), 48 | horizontalArrangement = spacedBy(8.dp), 49 | verticalAlignment = CenterVertically 50 | ) { 51 | FractalNavChild( 52 | galaxy.fractalKey, 53 | Modifier 54 | .size(64.dp) 55 | .wrapContentSize() 56 | ) { 57 | GalaxyChild(galaxy, universeInfo) 58 | } 59 | Text(galaxy.name) 60 | } 61 | } 62 | } 63 | 64 | @OptIn(ExperimentalFoundationApi::class) 65 | @Composable 66 | private fun FractalNavChildScope.GalaxyChild(galaxy: Galaxy, universeInfo: UniverseInfo) { 67 | val scrollState = rememberScrollState() 68 | val bringHeroIntoViewRequester = remember { BringIntoViewRequester() } 69 | 70 | // When zooming out, scroll back to the top, animating in coordination with the zoom. 71 | if (zoomDirection == ZoomingOut) { 72 | LaunchedEffect(scrollState) { 73 | val amountToScroll = scrollState.value 74 | snapshotFlow { zoomFactor } 75 | .collect { 76 | scrollState.scrollTo(lerp(0, amountToScroll, it)) 77 | } 78 | } 79 | } 80 | 81 | Column( 82 | verticalArrangement = spacedBy(8.dp), 83 | modifier = if (isActive) Modifier.verticalScroll(scrollState) else Modifier 84 | ) { 85 | GalaxyHero( 86 | galaxy, 87 | showControls = isFullyZoomedIn || zoomDirection == ZoomingIn, 88 | modifier = if (isActive) { 89 | Modifier.bringIntoViewRequester(bringHeroIntoViewRequester) 90 | } else Modifier 91 | ) 92 | 93 | if (isActive) { 94 | val stars = remember(galaxy, universeInfo) { universeInfo.getStars(galaxy) } 95 | .collectAsState().value ?: emptyList() 96 | 97 | InfoText( 98 | text = galaxy.description, 99 | modifier = Modifier 100 | .fillExpandedWidth() 101 | .alphaByZoomFactor() 102 | ) 103 | 104 | if (stars.isNotEmpty()) { 105 | ListHeader("Stars") 106 | SpaceList(stars) { star -> 107 | StarItem(star, universeInfo, Modifier.fillMaxWidth()) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | @Composable 115 | private fun FractalNavChildScope.GalaxyHero( 116 | galaxy: Galaxy, 117 | showControls: Boolean, 118 | modifier: Modifier = Modifier 119 | ) { 120 | Box(modifier) { 121 | GalaxyImage( 122 | galaxy, Modifier 123 | .fillMaxWidth() 124 | .graphicsLayer { 125 | clip = true 126 | shape = RoundedCornerShape(10.dp * (1f - zoomFactor)) 127 | } 128 | ) 129 | AnimatedVisibility(showControls, Modifier.align(TopStart)) { 130 | BackButton() 131 | } 132 | AnimatedVisibility( 133 | showControls, 134 | modifier = Modifier.align(Center), 135 | enter = fadeIn(), 136 | exit = fadeOut() 137 | ) { 138 | Text( 139 | "The ${galaxy.name} Galaxy", 140 | style = MaterialTheme.typography.h4 141 | .copy( 142 | shadow = Shadow(blurRadius = 5f), 143 | textAlign = TextAlign.Center, 144 | ), 145 | modifier = Modifier 146 | .alphaByZoomFactor() 147 | .scaleByZoomFactor() 148 | .fillExpandedWidth() 149 | .padding(8.dp) 150 | ) 151 | } 152 | } 153 | } 154 | 155 | @Composable 156 | private fun GalaxyImage(galaxy: Galaxy, modifier: Modifier) { 157 | NetworkImage( 158 | url = galaxy.imageUrl, 159 | contentDescription = "Image of ${galaxy.name}", 160 | modifier = modifier, 161 | blendMode = BlendMode.Screen, 162 | alignment = TopCenter, 163 | cacheOriginal = true, 164 | ) 165 | } 166 | 167 | -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/ImageLoading.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.key 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.draw.drawWithContent 9 | import androidx.compose.ui.geometry.Offset 10 | import androidx.compose.ui.geometry.Rect 11 | import androidx.compose.ui.graphics.BlendMode 12 | import androidx.compose.ui.graphics.ColorFilter 13 | import androidx.compose.ui.graphics.Paint 14 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 15 | import androidx.compose.ui.graphics.withSaveLayer 16 | import androidx.compose.ui.layout.ContentScale 17 | import androidx.compose.ui.platform.LocalContext 18 | import coil.compose.AsyncImage 19 | import coil.imageLoader 20 | import coil.request.ImageRequest 21 | import coil.size.Dimension 22 | 23 | /** 24 | * Thin wrapper around external image loader library. 25 | * 26 | * @param key An arbitrary value that will cause the image request to be re-started when it changes 27 | * between compositions. Use to workaround the Coil bug where the image is initially loaded into a 28 | * very small space and then that space grows. 29 | */ 30 | @Composable 31 | fun NetworkImage( 32 | url: String, 33 | contentDescription: String?, 34 | modifier: Modifier = Modifier, 35 | contentScale: ContentScale = ContentScale.FillWidth, 36 | alignment: Alignment = Alignment.Center, 37 | blendMode: BlendMode? = null, 38 | colorFilter: ColorFilter? = null, 39 | cacheOriginal: Boolean = false, 40 | ) { 41 | if (cacheOriginal) { 42 | // Warm the cache with the image as large to workaround a Coil bug that doesn't reload the 43 | // image at a higher resolution after loading it for a very small size. 44 | val context = LocalContext.current 45 | LaunchedEffect(context) { 46 | context.imageLoader.execute( 47 | ImageRequest.Builder(context) 48 | .data(url) 49 | .size(Dimension.Original, Dimension.Original) 50 | .build() 51 | ) 52 | } 53 | } 54 | 55 | AsyncImage( 56 | model = ImageRequest.Builder(LocalContext.current) 57 | .data(url) 58 | .build(), 59 | contentDescription = contentDescription, 60 | contentScale = contentScale, 61 | colorFilter = colorFilter, 62 | alignment = alignment, 63 | modifier = modifier.then(blendMode?.let(Modifier::blendMode) ?: Modifier), 64 | ) 65 | } 66 | 67 | fun Modifier.blendMode(mode: BlendMode): Modifier = drawWithContent { 68 | drawIntoCanvas { 69 | it.withSaveLayer( 70 | bounds = Rect(Offset.Zero, size), 71 | paint = Paint().apply { 72 | blendMode = mode 73 | } 74 | ) { 75 | drawContent() 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import com.zachklipp.fractalnav.FractalNavState 7 | import com.zachklipp.galaxyapp.ui.theme.FractalnavTheme 8 | 9 | class MainActivity : ComponentActivity() { 10 | 11 | private var navState: FractalNavState? = null 12 | 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | 16 | @Suppress("DEPRECATION") 17 | val navState = navState ?: run { 18 | (lastCustomNonConfigurationInstance as? FractalNavState) ?: FractalNavState() 19 | }.also { navState = it } 20 | 21 | setContent { 22 | FractalnavTheme { 23 | // A surface container using the 'background' color from the theme 24 | App(navState) 25 | } 26 | } 27 | } 28 | 29 | @Suppress("OVERRIDE_DEPRECATION") 30 | override fun onRetainCustomNonConfigurationInstance(): Any? = navState 31 | } 32 | -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/PlanetItem.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.layout.Arrangement.spacedBy 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.drawWithContent 17 | import androidx.compose.ui.graphics.* 18 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 19 | import androidx.compose.ui.text.style.TextAlign 20 | import androidx.compose.ui.unit.dp 21 | import com.zachklipp.fractalnav.FractalNavChildScope 22 | import com.zachklipp.fractalnav.ZoomDirection 23 | 24 | @Composable 25 | fun FractalNavChildScope.PlanetItem( 26 | planet: Planet, 27 | modifier: Modifier = Modifier 28 | ) { 29 | Column( 30 | modifier = modifier, 31 | verticalArrangement = spacedBy(8.dp) 32 | ) { 33 | PlanetHero( 34 | planet, 35 | showControls = isFullyZoomedIn || zoomDirection == ZoomDirection.ZoomingIn 36 | ) 37 | 38 | if (isActive) { 39 | InfoText( 40 | text = planet.description, 41 | modifier = Modifier 42 | .fillExpandedWidth() 43 | .alphaByZoomFactor() 44 | ) 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | private fun FractalNavChildScope.PlanetHero( 51 | planet: Planet, 52 | showControls: Boolean, 53 | modifier: Modifier = Modifier 54 | ) { 55 | Box( 56 | modifier 57 | .fillMaxWidth() 58 | .aspectRatio(1f), 59 | ) { 60 | // Spin the planet when zoomed out, but slow it way down when zoomed in. 61 | val rotationAngle by animateRotation(3_000, scale = { 1f - zoomFactor * 0.9f }) 62 | PlanetImage( 63 | planet = planet, 64 | // Darken the image a bit when zoomed in so the white text shows up better. 65 | tint = zoomFactor * 0.5f, 66 | modifier = modifier 67 | .fillMaxSize() 68 | .graphicsLayer { rotationZ = rotationAngle } 69 | // Since the images are drawn with the Screen blendmode, they're 70 | // translucent. We need an opaque background to block out the orbit 71 | // line. 72 | .background(MaterialTheme.colors.background, CircleShape) 73 | .drawWithContent { 74 | drawIntoCanvas { } 75 | drawContent() 76 | } 77 | ) 78 | AnimatedVisibility(showControls, Modifier.align(Alignment.TopStart)) { 79 | BackButton() 80 | } 81 | AnimatedVisibility( 82 | showControls, 83 | modifier = Modifier.align(Alignment.Center), 84 | enter = fadeIn(), 85 | exit = fadeOut() 86 | ) { 87 | Text( 88 | planet.name, 89 | style = MaterialTheme.typography.h4 90 | .copy( 91 | shadow = Shadow(blurRadius = 5f), 92 | textAlign = TextAlign.Center, 93 | ), 94 | modifier = Modifier 95 | .alphaByZoomFactor() 96 | .scaleByZoomFactor() 97 | .fillExpandedWidth() 98 | .padding(8.dp) 99 | ) 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | private fun PlanetImage( 106 | planet: Planet, 107 | modifier: Modifier, 108 | tint: Float 109 | ) { 110 | NetworkImage( 111 | url = planet.imageUrl, 112 | contentDescription = "Image of ${planet.name}", 113 | modifier = modifier, 114 | blendMode = BlendMode.Screen, 115 | colorFilter = ColorFilter.tint(Color.Black.copy(alpha = tint), BlendMode.Darken), 116 | // The planets start off being measured only a few pixels, so Coil will cache that scaled- 117 | // down image and never refresh it even when the size grows unless we explicitly cache the 118 | // original size. 119 | cacheOriginal = true 120 | ) 121 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/PlanetarySystem.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.foundation.gestures.awaitDragOrCancellation 5 | import androidx.compose.foundation.gestures.awaitFirstDown 6 | import androidx.compose.foundation.gestures.forEachGesture 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.runtime.* 9 | import androidx.compose.runtime.saveable.rememberSaveable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.drawBehind 12 | import androidx.compose.ui.draw.drawWithCache 13 | import androidx.compose.ui.geometry.Offset 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.drawscope.Stroke 16 | import androidx.compose.ui.graphics.drawscope.scale 17 | import androidx.compose.ui.graphics.graphicsLayer 18 | import androidx.compose.ui.input.pointer.PointerInputChange 19 | import androidx.compose.ui.input.pointer.consumePositionChange 20 | import androidx.compose.ui.input.pointer.pointerInput 21 | import androidx.compose.ui.layout.layout 22 | import androidx.compose.ui.unit.Constraints 23 | import androidx.compose.ui.unit.IntOffset 24 | import kotlin.math.roundToInt 25 | 26 | /** 27 | * An animated, scientifically-inaccurate model of a star and its planets. 28 | * 29 | * @param orbitScale A function that returns a value between 0 and 1 that controls how much the 30 | * planet orbits are scaled around the center. 0 means all planets will be pinned to the center. 31 | * @param orbitAnimationScale A function that returns a value between 0 and 1 that controls the 32 | * speed of the orbits. 33 | */ 34 | @Composable 35 | fun

PlanetarySystem( 36 | star: @Composable () -> Unit, 37 | planets: List

, 38 | modifier: Modifier = Modifier, 39 | orbitScale: () -> Float = { 1f }, 40 | orbitAnimationScale: () -> Float = { 1f }, 41 | onPlanetTouched: ((Int) -> Unit)? = null, 42 | onPlanetSelected: ((Int) -> Unit)? = null, 43 | planetContent: @Composable (P) -> Unit 44 | ) { 45 | val planetRadii = remember { mutableStateListOf() } 46 | var selectedPlanet by remember { mutableStateOf(-1) } 47 | 48 | // Keep the size of the list of radii in sync with the actual planets list so when the 49 | // onRadiusMeasured callback fires it's always exactly the right size. 50 | SideEffect { 51 | if (planetRadii.size > planets.size) { 52 | planetRadii.removeRange(planets.size, planetRadii.size) 53 | } else if (planets.size > planetRadii.size) { 54 | for (i in planetRadii.size until planets.size) { 55 | planetRadii.add(0f) 56 | } 57 | } 58 | } 59 | 60 | val drawOrbitsModifier = Modifier.drawBehind { 61 | planetRadii.forEach { orbitRadius -> 62 | drawCircle( 63 | Color.Gray, 64 | radius = orbitRadius, 65 | style = Stroke(), 66 | alpha = 0.5f * orbitScale() 67 | ) 68 | 69 | if (selectedPlanet in planetRadii.indices) { 70 | drawCircle( 71 | Color.LightGray, 72 | radius = planetRadii[selectedPlanet], 73 | alpha = 0.5f 74 | ) 75 | } 76 | } 77 | } 78 | 79 | val inputModifier = if (onPlanetSelected != null && planets.isNotEmpty()) { 80 | val updatedOnPlanetTouched by rememberUpdatedState(onPlanetTouched) 81 | val updatedOnPlanetSelected by rememberUpdatedState(onPlanetSelected) 82 | Modifier.pointerInput(planets) { 83 | forEachGesture { 84 | awaitPointerEventScope { 85 | var change: PointerInputChange? = awaitFirstDown() 86 | while (change != null && change.pressed) { 87 | val totalRadius = size.width / 2f 88 | val radius = (change.position - Offset( 89 | totalRadius, 90 | totalRadius 91 | )).getDistance() / totalRadius 92 | selectedPlanet = if (radius <= 0.3f || radius > 1f) { 93 | -1 94 | } else { 95 | (planets.size * (radius - 0.3f) / .7f / orbitScale()).toInt() 96 | } 97 | updatedOnPlanetTouched?.invoke(selectedPlanet) 98 | change.consumePositionChange() 99 | change = awaitDragOrCancellation(change.id) 100 | } 101 | 102 | // Pointer was either raised or cancelled. 103 | if (change != null && selectedPlanet != -1) { 104 | updatedOnPlanetSelected(selectedPlanet) 105 | } 106 | selectedPlanet = -1 107 | } 108 | } 109 | } 110 | } else Modifier 111 | 112 | RadialLayout( 113 | modifier 114 | .then(drawOrbitsModifier) 115 | .then(inputModifier) 116 | ) { 117 | val transition = rememberInfiniteTransition() 118 | 119 | val starAngle by animateRotation(20_000) 120 | val starTwinkleScale by transition.animateFloat( 121 | initialValue = 0.95f, 122 | targetValue = 1.05f, 123 | animationSpec = infiniteRepeatable( 124 | tween(500), 125 | repeatMode = RepeatMode.Reverse 126 | ) 127 | ) 128 | Box( 129 | Modifier 130 | // Make the star twice as big as the planets. 131 | .weight { 2f } 132 | .centerOffsetPercent { 0f } 133 | .scaleConstraints { 0.5f + 0.5f * (1f - orbitScale()) } 134 | .graphicsLayer { 135 | rotationZ = -starAngle 136 | } 137 | .drawWithCache { 138 | onDrawWithContent { 139 | drawContent() 140 | // Layer the star so that it looks glowy. 141 | scale(starTwinkleScale) { 142 | this@onDrawWithContent.drawContent() 143 | } 144 | } 145 | }, 146 | propagateMinConstraints = true 147 | ) { 148 | star() 149 | } 150 | 151 | planets.forEachIndexed { i, planet -> 152 | val orbitAngle by animateRotation( 153 | duration = 2_000 * (i + 1), 154 | scale = orbitAnimationScale 155 | ) 156 | Box( 157 | Modifier 158 | .weight(orbitScale) 159 | .centerOffsetPercent { 160 | 0.3f + 0.7f * (i / planets.size.toFloat() * orbitScale()) 161 | } 162 | .onRadiusMeasured { 163 | planetRadii[i] = it 164 | } 165 | .angleDegrees { -orbitAngle } 166 | .scaleConstraints { 0.5f } 167 | ) { 168 | planetContent(planet) 169 | } 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Insets and centers a layout by a [factor] of its constraints. 176 | * Works around the [scale] modifier not being used correctly in calculations. 177 | */ 178 | private fun Modifier.scaleConstraints(factor: () -> Float): Modifier = layout { m, c -> 179 | @Suppress("NAME_SHADOWING") 180 | val scale = factor() 181 | if (scale == 0f) { 182 | // Don't measure or place if it won't take any space anyway. 183 | return@layout layout(0, 0) {} 184 | } 185 | 186 | val constraints = Constraints( 187 | minWidth = (c.minWidth * scale).roundToInt(), 188 | minHeight = (c.minHeight * scale).roundToInt(), 189 | maxWidth = (c.maxWidth * scale).roundToInt(), 190 | maxHeight = (c.maxHeight * scale).roundToInt() 191 | ) 192 | val p = m.measure(constraints) 193 | layout(c.maxWidth, c.maxHeight) { 194 | val center = IntOffset(c.maxWidth, c.maxHeight) / 2f 195 | val pCenter = IntOffset(p.width, p.height) / 2f 196 | p.place(center - pCenter) 197 | } 198 | } 199 | 200 | /** 201 | * Returns a value that will animate continuously between 0 and 360 in [duration] millis * [scale]. 202 | * The rotation angle is saved in the instance state. 203 | */ 204 | @Composable 205 | fun animateRotation(duration: Int, scale: () -> Float = { 1f }): State { 206 | val angle = rememberSaveable { mutableStateOf(0f) } 207 | val running by remember { derivedStateOf { scale() != 0f } } 208 | if (running) { 209 | LaunchedEffect(duration) { 210 | var previousTime: Long = AnimationConstants.UnspecifiedTime 211 | val degreesPerMilli = 360f / duration 212 | while (true) { 213 | withInfiniteAnimationFrameMillis { frame -> 214 | if (previousTime == AnimationConstants.UnspecifiedTime) previousTime = frame 215 | val angleChange = 216 | angle.value + degreesPerMilli * (frame - previousTime) * scale() 217 | angle.value = angleChange.mod(360f) 218 | previousTime = frame 219 | } 220 | } 221 | } 222 | } 223 | return angle 224 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/RadialLayout.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.layout.Layout 7 | import androidx.compose.ui.layout.ParentDataModifier 8 | import androidx.compose.ui.unit.Constraints 9 | import androidx.compose.ui.unit.Density 10 | import androidx.compose.ui.unit.round 11 | import kotlin.math.PI 12 | import kotlin.math.cos 13 | import kotlin.math.roundToInt 14 | import kotlin.math.sin 15 | 16 | private const val HalfPI = PI.toFloat() / 180f 17 | 18 | object RadialLayoutScope { 19 | /** 20 | * Specifies the offset in pixels from the center of a [RadialLayout] to place the center of 21 | * this element. 22 | */ 23 | fun Modifier.centerOffsetPercent(radiusPercent: () -> Float): Modifier = radialParentData { 24 | it.centerOffsetPercent = radiusPercent 25 | } 26 | 27 | /** 28 | * Specifies the angle to place this element around the center of a [RadialLayout]. 29 | */ 30 | fun Modifier.angleDegrees(angle: () -> Float): Modifier = radialParentData { 31 | it.angleDegrees = angle 32 | } 33 | 34 | /** 35 | * Specifies the weight to measure this element relative to the other children of this 36 | * [RadialLayout]. 37 | */ 38 | fun Modifier.weight(weight: () -> Float): Modifier = radialParentData { 39 | it.weight = weight 40 | } 41 | 42 | /** 43 | * Register a callback to receive the element's distance from the center when measured. 44 | */ 45 | fun Modifier.onRadiusMeasured(block: (Float) -> Unit): Modifier = radialParentData { it -> 46 | val previousCallback = it.onRadiusMeasured 47 | it.onRadiusMeasured = { radius -> 48 | previousCallback?.invoke(radius) 49 | block(radius) 50 | } 51 | } 52 | 53 | private fun Modifier.radialParentData(block: (RadialLayoutParentData) -> Unit) = 54 | this.then(object : ParentDataModifier { 55 | override fun Density.modifyParentData(parentData: Any?) = 56 | ((parentData as? RadialLayoutParentData) ?: RadialLayoutParentData()).also { 57 | block(it) 58 | } 59 | }) 60 | } 61 | 62 | private data class RadialLayoutParentData( 63 | var centerOffsetPercent: () -> Float = { 0f }, 64 | var angleDegrees: () -> Float = { 0f }, 65 | var weight: (() -> Float)? = null, 66 | var onRadiusMeasured: ((Float) -> Unit)? = null 67 | ) 68 | 69 | /** 70 | * Lays children out in a circle around the center of the layout. 71 | */ 72 | @Composable 73 | fun RadialLayout( 74 | modifier: Modifier = Modifier, 75 | content: @Composable RadialLayoutScope.() -> Unit 76 | ) { 77 | Layout( 78 | modifier = modifier, 79 | content = { 80 | RadialLayoutScope.content() 81 | }, 82 | ) { measurables, constraints -> 83 | val minDimension = minOf(constraints.maxWidth, constraints.maxHeight) 84 | val maxRadius = minDimension / 2f 85 | val center = Offset(maxRadius, maxRadius) 86 | var totalWeight = 0f 87 | 88 | // Only consider nodes with parent data. 89 | val (weighted, unweighted) = measurables 90 | .mapNotNull { 91 | val layoutData = 92 | (it.parentData as? RadialLayoutParentData) ?: return@mapNotNull null 93 | totalWeight += layoutData.weight?.invoke() ?: 0f 94 | Pair(it, layoutData) 95 | } 96 | .partition { (_, data) -> data.weight.let { it != null && !it.invoke().isNaN() } } 97 | 98 | // TODO measure unweighted placeables. 99 | val weightedPlaceables = weighted.map { (measurable, data) -> 100 | val size: Int = if (totalWeight == 0f) { 101 | minDimension 102 | } else { 103 | (minDimension * (data.weight!!() / totalWeight)).roundToInt() 104 | } 105 | val weightedConstraints = Constraints.fixed(size, size) 106 | Pair(measurable.measure(weightedConstraints), data) 107 | } 108 | 109 | layout(minDimension, minDimension) { 110 | weightedPlaceables.forEachIndexed { i, (placeable, data) -> 111 | val offset = data.centerOffsetPercent() 112 | val centerRadius = maxRadius * offset 113 | data.onRadiusMeasured?.invoke(centerRadius) 114 | val angle = data.angleDegrees() * HalfPI 115 | val centerPosition = center + Offset( 116 | x = centerRadius * cos(angle), 117 | y = centerRadius * sin(angle) 118 | ) 119 | val topLeftPosition = 120 | centerPosition - Offset(placeable.width / 2f, placeable.height / 2f) 121 | placeable.place(topLeftPosition.round()) 122 | } 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/Sample.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.activity.compose.BackHandler 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.wrapContentSize 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Surface 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.darkColors 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.graphicsLayer 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import com.zachklipp.fractalnav.FractalNavChildScope 19 | import com.zachklipp.fractalnav.FractalNavHost 20 | import com.zachklipp.fractalnav.FractalNavScope 21 | 22 | @Preview 23 | @Composable 24 | private fun FractalNavSample() { 25 | MaterialTheme(colors = darkColors()) { 26 | Surface { 27 | // The host should wrap the root of your app. 28 | FractalNavHost(Modifier.fillMaxSize()) { 29 | Row(Modifier.wrapContentSize()) { 30 | Text("Click ") 31 | Link("here") { 32 | Text( 33 | "42", 34 | // Scale the text in when clicked. 35 | modifier = Modifier.scaleByZoomFactor(), 36 | style = MaterialTheme.typography.h1, 37 | maxLines = 1 38 | ) 39 | } 40 | Text(" to learn more.") 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | @Composable 48 | fun FractalNavScope.Link( 49 | text: String, 50 | content: @Composable FractalNavChildScope.() -> Unit 51 | ) { 52 | // This creates some content that can be zoomed into. 53 | FractalNavChild( 54 | // It's identified by a string key… 55 | key = "link", 56 | modifier = Modifier.clickable { 57 | // …which can be used to expand its content. This will animate 58 | // the content block below to take up the full screen and also 59 | // zoom the parent content, that called this composable, out of 60 | // view. 61 | zoomToChild("link") 62 | } 63 | ) { 64 | Box(contentAlignment = Alignment.Center) { 65 | Text(text, Modifier.graphicsLayer { 66 | // The zoomFactor property is available inside the FractalNavChild 67 | // block. It starts at 0, then when zoomToChild is called it will 68 | // be animated up to 1. In this case, we want this text to start 69 | // at the full size and shrink when zoomed in. 70 | alpha = 1f - zoomFactor 71 | }) 72 | 73 | // The isActive flag is also provided inside the FractalNavChild 74 | // content block, and means `zoomFactor > 0` – but is backed by 75 | // a derivedStateOf so it won't invalidate more than once during 76 | // the zoom animation. 77 | if (isActive) { 78 | content() 79 | BackHandler { 80 | // This will animate zoomFactor back down to 0. 81 | zoomToParent() 82 | } 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/StarItem.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.Card 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Alignment.Companion.CenterHorizontally 9 | import androidx.compose.ui.Alignment.Companion.CenterVertically 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clipToBounds 12 | import androidx.compose.ui.graphics.BlendMode 13 | import androidx.compose.ui.unit.dp 14 | import com.zachklipp.fractalnav.FractalNavChildScope 15 | import com.zachklipp.fractalnav.FractalNavScope 16 | import kotlin.math.roundToInt 17 | 18 | private val Star.fractalKey get() = "star-$name" 19 | 20 | @Composable 21 | fun FractalNavScope.StarItem( 22 | star: Star, 23 | universeInfo: UniverseInfo, 24 | modifier: Modifier = Modifier 25 | ) { 26 | Card( 27 | modifier.clickable { zoomToChild(star.fractalKey) } 28 | ) { 29 | Row( 30 | modifier = Modifier.padding(8.dp), 31 | horizontalArrangement = Arrangement.Absolute.spacedBy(8.dp), 32 | verticalAlignment = CenterVertically 33 | ) { 34 | FractalNavChild( 35 | star.fractalKey, 36 | Modifier 37 | .size(64.dp) 38 | .wrapContentSize() 39 | .clipToBounds() 40 | ) { 41 | StarChild(star, universeInfo) 42 | } 43 | // Set maxlines to 1 to avoid wrapping when close to fully zoomed-out. 44 | Text(star.name, maxLines = 1) 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | private fun FractalNavChildScope.StarChild(star: Star, universeInfo: UniverseInfo) { 51 | val planets: List = if (isActive) { 52 | remember(star, universeInfo) { universeInfo.getPlanets(star) } 53 | .collectAsState() 54 | .value ?: emptyList() 55 | } else { 56 | emptyList() 57 | } 58 | var planetUnderFinger by remember { mutableStateOf(-1) } 59 | 60 | Column( 61 | verticalArrangement = Arrangement.SpaceBetween, 62 | horizontalAlignment = CenterHorizontally 63 | ) { 64 | if (isActive) { 65 | Row( 66 | verticalAlignment = CenterVertically, 67 | modifier = Modifier 68 | .scaleLayoutByZoomFactor() 69 | .alphaByZoomFactor() 70 | ) { 71 | BackButton() 72 | Spacer(Modifier.size(8.dp)) 73 | Text("The ${star.name} System", Modifier.weight(1f), maxLines = 1) 74 | } 75 | Text( 76 | if (planetUnderFinger in planets.indices) { 77 | "${planets[planetUnderFinger].name}, release to open." 78 | } else { 79 | "Tap or drag on the planets." 80 | }, 81 | maxLines = 1, 82 | modifier = Modifier 83 | .scaleLayoutByZoomFactor() 84 | .alphaByZoomFactor() 85 | ) 86 | } 87 | 88 | PlanetarySystem( 89 | star = { StarImage(star) }, 90 | planets = planets, 91 | // When zooming in, animate the planets out from the center. 92 | orbitScale = { zoomFactor }, 93 | // Slow then stop animation when a child is being zoomed in. 94 | orbitAnimationScale = { 1f - childZoomFactor }, 95 | onPlanetTouched = { 96 | planetUnderFinger = it 97 | }, 98 | onPlanetSelected = { 99 | planetUnderFinger = -1 100 | if (it in planets.indices) { 101 | zoomToChild("planet-${planets[it].name}") 102 | } 103 | } 104 | ) { planet -> 105 | FractalNavChild( 106 | key = "planet-${planet.name}", 107 | modifier = Modifier 108 | ) { 109 | PlanetItem(planet) 110 | } 111 | } 112 | 113 | if (isActive) { 114 | InfoText( 115 | text = star.description, 116 | modifier = Modifier 117 | .fillExpandedWidth() 118 | .alphaByZoomFactor() 119 | ) 120 | } 121 | 122 | // Empty spacer just to take up the bottom slot in the column. 123 | Spacer(Modifier) 124 | } 125 | } 126 | 127 | @Composable 128 | private fun StarImage(star: Star, modifier: Modifier = Modifier) { 129 | NetworkImage( 130 | url = star.imageUrl, 131 | contentDescription = "Image of ${star.name}", 132 | modifier = modifier, 133 | blendMode = BlendMode.Screen, 134 | ) 135 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/Universe.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.ui.Alignment.Companion.CenterHorizontally 11 | import androidx.compose.ui.Modifier 12 | import com.zachklipp.fractalnav.FractalNavScope 13 | 14 | @Composable 15 | fun FractalNavScope.Universe(universeInfo: UniverseInfo) { 16 | val galaxies by universeInfo.galaxies.collectAsState() 17 | 18 | Column(horizontalAlignment = CenterHorizontally) { 19 | ListHeader("Galaxies") 20 | 21 | @Suppress("NAME_SHADOWING") 22 | Crossfade(galaxies) { galaxies -> 23 | if (galaxies == null) { 24 | CircularProgressIndicator() 25 | } else { 26 | SpaceGrid(galaxies) { galaxy -> 27 | GalaxyItem(galaxy, universeInfo, Modifier.fillMaxWidth()) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/UniverseInfo.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.geometry.isSpecified 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | 10 | private val knownGalaxies = listOf( 11 | Galaxy( 12 | name = "Andromeda", 13 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Andromeda_Galaxy_560mm_FL.jpg/600px-Andromeda_Galaxy_560mm_FL.jpg", 14 | description = """ 15 | The Andromeda Galaxy (IPA: /ænˈdrɒmɪdə/), also known as Messier 31, M31, or NGC 224 and 16 | originally the Andromeda Nebula (see below), is a barred spiral galaxy with diameter of 17 | about 220,000 ly approximately 2.5 million light-years (770 kiloparsecs) from Earth and 18 | the nearest large galaxy to the Milky Way. The galaxy's name stems from the area of 19 | Earth's sky in which it appears, the constellation of Andromeda, which itself is named 20 | after the Ethiopian (or Phoenician) princess who was the wife of Perseus in Greek 21 | mythology. 22 | (from Wikipedia) 23 | """.trimIndent(), 24 | ), 25 | Galaxy( 26 | name = "Antennae", 27 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Antennae_Galaxies_reloaded.jpg/600px-Antennae_Galaxies_reloaded.jpg", 28 | description = """ 29 | The Antennae Galaxies (also known as NGC 4038/NGC 4039 or Caldwell 60/Caldwell 61) are a 30 | pair of interacting galaxies in the constellation Corvus. They are currently going 31 | through a starburst phase, in which the collision of clouds of gas and dust, with 32 | entangled magnetic fields, causes rapid star formation. They were discovered by William 33 | Herschel in 1785. 34 | (from Wikipedia) 35 | """.trimIndent(), 36 | ), 37 | Galaxy( 38 | name = "Backward", 39 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/NGC_4622HSTFull.jpg/600px-NGC_4622HSTFull.jpg", 40 | description = """ 41 | NGC 4622 is a face-on unbarred spiral galaxy with a very prominent ring structure 42 | located in the constellation Centaurus. The galaxy is a member of the Centaurus Cluster. 43 | (from Wikipedia) 44 | """.trimIndent(), 45 | ), 46 | Galaxy( 47 | name = "Black Eye", 48 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/NGC_4826_-_HST.png/600px-NGC_4826_-_HST.png", 49 | description = """ 50 | The Black Eye Galaxy (also called Sleeping Beauty Galaxy or Evil Eye Galaxy and 51 | designated Messier 64, M64, or NGC 4826) is a relatively isolated[7] spiral galaxy 17 52 | million light-years away in the mildly northern constellation of Coma Berenices. It was 53 | discovered by Edward Pigott in March 1779, and independently by Johann Elert Bode in 54 | April of the same year, as well as by Charles Messier the next year. A dark band of 55 | absorbing dust partially in front of its bright nucleus gave rise to its nicknames of 56 | the "Black Eye", "Evil Eye", or "Sleeping Beauty" galaxy.[10][11] M64 is well known 57 | among amateur astronomers due to its form in small telescopes and visibility across 58 | inhabited latitudes. 59 | (from Wikipedia) 60 | """.trimIndent(), 61 | ), 62 | Galaxy( 63 | name = "Bode's", 64 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Messier_81_HST.jpg/600px-Messier_81_HST.jpg" 65 | ), 66 | Galaxy( 67 | name = "Butterfly", 68 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/NGC_4567_%26_4568.png/600px-NGC_4567_%26_4568.png" 69 | ), 70 | Galaxy( 71 | name = "Cartwheel", 72 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Cartwheel_Galaxy.jpg/500px-Cartwheel_Galaxy.jpg" 73 | ), 74 | Galaxy( 75 | name = "Cigar", 76 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/M82_HST_ACS_2006-14-a-large_web.jpg/600px-M82_HST_ACS_2006-14-a-large_web.jpg" 77 | ), 78 | Galaxy( 79 | name = "Circinus", 80 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/64/142_circinus_galaxy.png/600px-142_circinus_galaxy.png" 81 | ), 82 | Galaxy( 83 | name = "Milky Way", 84 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/ESO-VLT-Laser-phot-33a-07.jpg/600px-ESO-VLT-Laser-phot-33a-07.jpg", 85 | description = """ 86 | The Milky Way[a] is the galaxy that includes our Solar System, with the name describing 87 | the galaxy's appearance from Earth: a hazy band of light seen in the night sky formed 88 | from stars that cannot be individually distinguished by the naked eye. The term Milky 89 | Way is a translation of the Latin via lactea, from the Greek γαλακτικός κύκλος 90 | (galaktikos kýklos), meaning "milky circle."[20][21][22] From Earth, the Milky Way 91 | appears as a band because its disk-shaped structure is viewed from within. Galileo 92 | Galilei first resolved the band of light into individual stars with his telescope in 93 | 1610. Until the early 1920s, most astronomers thought that the Milky Way contained all 94 | the stars in the Universe.[23] Following the 1920 Great Debate between the astronomers 95 | Harlow Shapley and Heber Curtis,[24] observations by Edwin Hubble showed that the Milky 96 | Way is just one of many galaxies. 97 | (from Wikipedia) 98 | """.trimIndent(), 99 | ), 100 | Galaxy( 101 | name = "Needle", 102 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Needle_Galaxy_4565.jpeg/600px-Needle_Galaxy_4565.jpeg", 103 | description = """ 104 | NGC 4565 (also known as the Needle Galaxy or Caldwell 38) is an edge-on spiral galaxy 105 | about 30 to 50 million light-years away in the constellation Coma Berenices.[2] It lies 106 | close to the North Galactic Pole and has a visual magnitude of approximately 10. It is 107 | known as the Needle Galaxy for its narrow profile.[4] First recorded in 1785 by William 108 | Herschel, it is a prominent example of an edge-on spiral galaxy.[5] 109 | (from Wikipedia) 110 | """.trimIndent(), 111 | ), 112 | Galaxy( 113 | name = "Wolf–Lundmark–Melotte", 114 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/01/The_WLM_galaxy_on_the_edge_of_the_Local_Group.jpg/500px-The_WLM_galaxy_on_the_edge_of_the_Local_Group.jpg", 115 | description = """ 116 | The Wolf–Lundmark–Melotte (WLM) is a barred irregular galaxy discovered in 1909 by Max 117 | Wolf, located on the outer edges of the Local Group. The discovery of the nature of the 118 | galaxy was accredited to Knut Lundmark and Philibert Jacques Melotte in 1926. It is in 119 | the constellation Cetus. 120 | (from Wikipedia) 121 | """.trimIndent(), 122 | ), 123 | Galaxy( 124 | name = "Pinwheel", 125 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/M101_hires_STScI-PRC2006-10a.jpg/600px-M101_hires_STScI-PRC2006-10a.jpg", 126 | ), 127 | Galaxy( 128 | name = "Sculptor", 129 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Sculptor_Galaxy_by_VISTA.jpg/560px-Sculptor_Galaxy_by_VISTA.jpg", 130 | ), 131 | Galaxy( 132 | name = "Sombrero", 133 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/M104_ngc4594_sombrero_galaxy_hi-res.jpg/620px-M104_ngc4594_sombrero_galaxy_hi-res.jpg", 134 | ), 135 | Galaxy( 136 | name = "Southern Pinwheel", 137 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Hubble_view_of_barred_spiral_galaxy_Messier_83.jpg/600px-Hubble_view_of_barred_spiral_galaxy_Messier_83.jpg", 138 | ), 139 | Galaxy( 140 | name = "Sunflower", 141 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/M63_%28NGC_5055%29.jpg/600px-M63_%28NGC_5055%29.jpg", 142 | ), 143 | Galaxy( 144 | name = "Tadpole", 145 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/UGC_10214HST.jpg/600px-UGC_10214HST.jpg", 146 | ), 147 | Galaxy( 148 | name = "Triangulum", 149 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/64/VST_snaps_a_very_detailed_view_of_the_Triangulum_Galaxy.jpg/500px-VST_snaps_a_very_detailed_view_of_the_Triangulum_Galaxy.jpg", 150 | ), 151 | Galaxy( 152 | name = "Whirlpool", 153 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Messier51_sRGB.jpg/600px-Messier51_sRGB.jpg", 154 | description = """ 155 | The Whirlpool Galaxy, also known as Messier 51a, M51a, and NGC 5194, is an interacting 156 | grand-design spiral galaxy with a Seyfert 2 active galactic nucleus.[5][6][7] It lies in 157 | the constellation Canes Venatici, and was the first galaxy to be classified as a spiral 158 | galaxy.[8] Its distance is 31 million light-years away from Earth.[9] 159 | The galaxy and its companion, NGC 5195,[10] are easily observed by amateur astronomers, 160 | and the two galaxies may be seen with binoculars.[11] The Whirlpool Galaxy has been 161 | extensively observed by professional astronomers, who study it to understand galaxy 162 | structure (particularly structure associated with the spiral arms) and galaxy 163 | interactions. 164 | (from Wikipedia) 165 | """.trimIndent(), 166 | ), 167 | ) 168 | 169 | private val starsByGalaxy = mapOf( 170 | "Milky Way" to listOf( 171 | Star( 172 | name = "Sun", 173 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/The_Sun_by_the_Atmospheric_Imaging_Assembly_of_NASA%27s_Solar_Dynamics_Observatory_-_20100819.jpg/440px-The_Sun_by_the_Atmospheric_Imaging_Assembly_of_NASA%27s_Solar_Dynamics_Observatory_-_20100819.jpg", 174 | description = """ 175 | The Sun is the star at the center of the Solar System. It is a nearly perfect ball 176 | of hot plasma,[18][19] heated to incandescence by nuclear fusion reactions in its 177 | core, radiating the energy mainly as visible light, ultraviolet light, and infrared 178 | radiation. It is by far the most important source of energy for life on Earth. Its 179 | diameter is about 1.39 million kilometers (864,000 miles), or 109 times that of 180 | Earth. Its mass is about 330,000 times that of Earth, and it accounts for about 181 | 99.86% of the total mass of the Solar System.[20] Roughly three quarters of the 182 | Sun's mass consists of hydrogen (~73%); the rest is mostly helium (~25%), with much 183 | smaller quantities of heavier elements, including oxygen, carbon, neon and iron.[21] 184 | (from Wikipedia) 185 | """.trimIndent(), 186 | ) 187 | ) 188 | ) 189 | 190 | private val planetsByStar = mapOf( 191 | "Sun" to listOf( 192 | Planet( 193 | name = "Mercury", 194 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Mercury_in_true_color.jpg/440px-Mercury_in_true_color.jpg", 195 | description = """ 196 | Mercury is the smallest planet in the Solar System and the closest to the Sun. Its 197 | orbit around the Sun takes 87.97 Earth days, the shortest of all the Sun's planets. 198 | It is named after the Roman god Mercurius (Mercury), god of commerce, messenger of 199 | the gods, and mediator between gods and mortals, corresponding to the Greek god 200 | Hermes (Ἑρμῆς). Like Venus, Mercury orbits the Sun within Earth's orbit as an 201 | inferior planet, and its apparent distance from the Sun as viewed from Earth never 202 | exceeds 28°. This proximity to the Sun means the planet can only be seen near the 203 | western horizon after sunset or the eastern horizon before sunrise, usually in 204 | twilight. At this time, it may appear as a bright star-like object, but is more 205 | difficult to observe than Venus. From Earth, the planet telescopically displays the 206 | complete range of phases, similar to Venus and the Moon, which recurs over its 207 | synodic period of approximately 116 days. 208 | (from Wikipedia) 209 | """.trimIndent() 210 | ), 211 | Planet( 212 | name = "Venus", 213 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Venus_from_Mariner_10.jpg/440px-Venus_from_Mariner_10.jpg", 214 | description = """ 215 | Venus is the second planet from the Sun. It is named after the Roman goddess of love 216 | and beauty. As the brightest natural object in Earth's night sky after the Moon, 217 | Venus can cast shadows and can be visible to the naked eye in broad daylight. 218 | Venus's orbit is smaller than that of Earth, but its maximal elongation is 47°; 219 | thus, it can be seen not only near the Sun in the morning or evening, but also a 220 | couple of hours before or after sunrise or sunset, depending on the observer's 221 | latitude and on the positions of Venus and the Sun. Most of the time, it can be seen 222 | either in the morning or in the evening. At some times, it may even be seen a while 223 | in a completely dark sky. Venus orbits the Sun every 224.7 Earth days.[20] It has a 224 | synodic day length of 117 Earth days and a sidereal rotation period of 243 Earth 225 | days. Consequently, it takes longer to rotate about its axis than any other planet 226 | in the Solar System, and does so in the opposite direction to all but Uranus. This 227 | means that the Sun rises from its western horizon and sets in its east.[21] Venus 228 | does not have any moons, a distinction it shares only with Mercury among the planets 229 | in the Solar System.[22] 230 | (from Wikipedia) 231 | """.trimIndent() 232 | ), 233 | Planet( 234 | name = "Earth", 235 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/The_Blue_Marble_%28remastered%29.jpg/440px-The_Blue_Marble_%28remastered%29.jpg", 236 | description = """ 237 | Earth is the third planet from the Sun and the only astronomical object known to 238 | harbor life. While large amounts of water can be found throughout the Solar System, 239 | only Earth sustains liquid surface water. About 71% of Earth's surface is made up of 240 | the ocean, dwarfing Earth's polar ice, lakes, and rivers. The remaining 29% of 241 | Earth's surface is land, consisting of continents and islands. Earth's surface layer 242 | is formed of several slowly moving tectonic plates, interacting to produce mountain 243 | ranges, volcanoes, and earthquakes. Earth's liquid outer core generates the magnetic 244 | field that shapes Earth's magnetosphere, deflecting destructive solar winds. 245 | (from Wikipedia) 246 | """.trimIndent() 247 | ), 248 | Planet( 249 | name = "Mars", 250 | imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/OSIRIS_Mars_true_color.jpg/440px-OSIRIS_Mars_true_color.jpg", 251 | description = """ 252 | Mars is the fourth planet from the Sun and the second-smallest planet in the Solar 253 | System, being larger than only Mercury. In English, Mars carries the name of the 254 | Roman god of war and is often called the "Red Planet".[17][18] The latter refers to 255 | the effect of the iron oxide prevalent on Mars's surface, which gives it a striking 256 | reddish appearance in the sky.[19] Mars is a terrestrial planet with a thin 257 | atmosphere, with surface features such as impact craters, valleys, dunes, and polar 258 | ice caps. 259 | (from Wikipedia) 260 | """.trimIndent() 261 | ), 262 | ) 263 | ) 264 | 265 | class UniverseInfo { 266 | val galaxies: StateFlow?> = MutableStateFlow(knownGalaxies) 267 | 268 | fun getStars(galaxy: Galaxy): StateFlow?> { 269 | return MutableStateFlow(starsByGalaxy[galaxy.name]) 270 | } 271 | 272 | fun getPlanets(star: Star): StateFlow?> { 273 | return MutableStateFlow(planetsByStar[star.name]) 274 | } 275 | } 276 | 277 | data class Galaxy( 278 | val name: String, 279 | val imageUrl: String, 280 | val description: String = "", 281 | ) 282 | 283 | data class Star( 284 | val name: String, 285 | val imageUrl: String, 286 | val description: String = "", 287 | ) 288 | 289 | data class Planet( 290 | val name: String, 291 | val imageUrl: String, 292 | val description: String = "", 293 | ) 294 | 295 | @Stable 296 | fun PolarOffset(angle: Float, radius: Float): PolarOffset = PolarOffset(Offset(angle, radius)) 297 | 298 | @JvmInline 299 | @Immutable 300 | value class PolarOffset internal constructor(private val offset: Offset) { 301 | @Stable 302 | val angle: Float 303 | get() = offset.x 304 | 305 | @Stable 306 | val radius: Float 307 | get() = offset.y 308 | 309 | @Stable 310 | val isSpecified: Boolean 311 | get() = offset.isSpecified 312 | 313 | @Stable 314 | override fun toString(): String { 315 | return if (isSpecified) { 316 | "PolarOffset(angle=$angle, radius=$radius)" 317 | } else { 318 | "PolarOffset.Unspecified" 319 | } 320 | } 321 | 322 | companion object { 323 | @Stable 324 | val Zero = PolarOffset(Offset.Zero) 325 | 326 | @Stable 327 | val Unspecified = PolarOffset(Offset.Unspecified) 328 | } 329 | } 330 | 331 | -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp.ui.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.darkColors 5 | import androidx.compose.runtime.Composable 6 | 7 | private val DarkColorPalette = darkColors( 8 | primary = Purple200, 9 | primaryVariant = Purple700, 10 | secondary = Teal200 11 | ) 12 | 13 | @Composable 14 | fun FractalnavTheme(content: @Composable () -> Unit) { 15 | MaterialTheme( 16 | colors = DarkColorPalette, 17 | typography = Typography, 18 | shapes = Shapes, 19 | content = content 20 | ) 21 | } -------------------------------------------------------------------------------- /galaxyapp/src/main/java/com/zachklipp/galaxyapp/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.galaxyapp.ui.theme 2 | 3 | import androidx.compose.material.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 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /galaxyapp/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /galaxyapp/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 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/galaxyapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /galaxyapp/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | fractal-nav 3 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /galaxyapp/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zach-klippenstein/compose-fractal-nav/bf661446f1dbd224cc7f03a8424d04405bc03fa5/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Apr 02 17:21:14 PDT 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "fractal-nav" 16 | include ':filebrowser' 17 | include ':fractalnav' 18 | include ':galaxyapp' 19 | --------------------------------------------------------------------------------