├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .idea └── copyright │ ├── Apache.xml │ └── profiles_settings.xml ├── LICENSE.txt ├── benchmarks ├── benchmarkable │ ├── .gitignore │ ├── build.gradle.kts │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── benchmarks │ │ │ └── BenchmarkRunTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ └── benchmarks │ │ │ └── data │ │ │ ├── Benchmarked.kt │ │ │ ├── Paging.kt │ │ │ └── Tiling.kt │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── benchmarks │ │ └── ExampleUnitTest.kt └── microbenchmark │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── microbenchmark │ │ └── AllocationBenchmark.kt │ └── main │ └── AndroidManifest.xml ├── build-logic ├── convention │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ ├── android-app-library-convention.kt │ │ ├── android-application-convention.gradle.kts │ │ ├── android-library-convention.gradle.kts │ │ ├── kotlin-jvm-convention.gradle.kts │ │ ├── kotlin-jvm-convention.kt │ │ ├── kotlin-library-convention.gradle.kts │ │ └── publishing-library-convention.gradle.kts ├── settings.gradle.kts └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── build.gradle.kts ├── contributing.md ├── docs ├── assets │ └── logo.png ├── images │ ├── adaptive.gif │ ├── basic.gif │ ├── complex.gif │ ├── placeholders.gif │ ├── search.gif │ └── sticky.gif ├── implementation │ ├── pagination-types.md │ ├── performance.md │ ├── pivoted-tiling.md │ ├── primitives.md │ └── tiledlist.md ├── index.md └── usecases │ ├── adaptive-paging.md │ ├── basic-example.md │ ├── complex-tiling.md │ ├── compose.md │ ├── overview.md │ ├── placeholders.md │ ├── search.md │ └── transformations.md ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── library ├── compose │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── tunjid │ │ └── tiler │ │ └── compose │ │ └── Effects.kt └── tiler │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── tunjid │ │ └── tiler │ │ ├── ConcurrentListTiler.kt │ │ ├── Pivoting.kt │ │ ├── Tile.kt │ │ ├── TiledList.kt │ │ ├── TiledListExt.kt │ │ ├── Tiler.kt │ │ └── utilities │ │ ├── ChunkedTiledList.kt │ │ ├── EmptyTiledList.kt │ │ ├── IntArrayList.kt │ │ ├── NeighbouredQueryFetcher.kt │ │ ├── SparseQueryArray.kt │ │ └── SparseTiledList.kt │ └── commonTest │ └── kotlin │ └── com │ └── tunjid │ ├── tiler │ ├── TileKtTest.kt │ ├── TiledListKtTest.kt │ ├── TilerKtTest.kt │ ├── TilingIterationOrderTest.kt │ └── Utilities.kt │ └── utilities │ ├── ChunkedTiledListTest.kt │ ├── IntArrayListTest.kt │ ├── NeighboredQueryFetcherTest.kt │ ├── PivotingKtTest.kt │ └── SparseTiledListTest.kt ├── libraryVersion.properties ├── misc └── demo.gif ├── mkdocs.yml ├── readme.md ├── sample ├── android │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── com │ │ │ └── tunjid │ │ │ └── tyler │ │ │ └── MainActivity.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.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml ├── browser │ ├── build.gradle.kts │ ├── resources │ │ ├── index.html │ │ └── styles.css │ └── src │ │ └── jsMain │ │ ├── kotlin │ │ └── main.js.kt │ │ └── resources │ │ ├── index.html │ │ └── styles.css ├── common │ ├── build.gradle.kts │ └── src │ │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── 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.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ └── values │ │ │ └── strings.xml │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tunjid │ │ │ └── demo │ │ │ └── common │ │ │ └── ui │ │ │ ├── Root.kt │ │ │ ├── Theme.kt │ │ │ └── numbers │ │ │ ├── AdaptiveTiledGrid.kt │ │ │ ├── Colors.kt │ │ │ ├── Loader.kt │ │ │ ├── NumberTile.kt │ │ │ ├── NumberUtilities.kt │ │ │ └── StickyHeaderTiledList.kt │ │ └── iosMain │ │ └── kotlin │ │ └── main.ios.kt ├── desktop │ ├── build.gradle.kts │ └── src │ │ └── jvmMain │ │ └── kotlin │ │ └── com │ │ └── tunjid │ │ └── demo │ │ └── Main.kt └── ios │ ├── tiler.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcuserdata │ │ │ └── adetunji_dahunsi.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ │ └── adetunji_dahunsi.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist │ └── tiler │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── tilerApp.swift └── settings.gradle.kts /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: JVM Tests 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@v2 17 | - name: Set up JDK 18 | uses: actions/setup-java@v2 19 | with: 20 | java-version: '17' 21 | distribution: 'adopt' 22 | - name: Validate Gradle wrapper 23 | uses: gradle/actions/wrapper-validation@v3 24 | - name: JVM tests 25 | run: ./gradlew jvmTest 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/copyright/Apache.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /benchmarks/benchmarkable/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /benchmarks/benchmarkable/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.jetbrains.kotlin.android") 3 | id("android-library-convention") 4 | } 5 | 6 | 7 | kotlin { 8 | jvmToolchain(17) 9 | } 10 | android { 11 | namespace = "com.example.benchmarks" 12 | buildFeatures { 13 | compose = false 14 | } 15 | defaultConfig { 16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 17 | testInstrumentationRunnerArguments["androidx.benchmark.profiling.mode"] = "StackSampling" 18 | testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR,LOW-BATTERY" 19 | } 20 | } 21 | 22 | dependencies { 23 | implementation(project(":library:tiler")) 24 | implementation(libs.androidx.paging) 25 | implementation(libs.kotlinx.coroutines.core) 26 | 27 | testImplementation(libs.kotlinx.coroutines.test) 28 | testImplementation(libs.androidx.test.core) 29 | testImplementation(libs.androidx.test.junit) 30 | testImplementation(libs.androidx.test.rules) 31 | testImplementation(libs.androidx.test.runner) 32 | 33 | androidTestImplementation(libs.kotlinx.coroutines.test) 34 | androidTestImplementation(libs.androidx.test.core) 35 | androidTestImplementation(libs.androidx.test.junit) 36 | androidTestImplementation(libs.androidx.test.rules) 37 | androidTestImplementation(libs.androidx.test.runner) 38 | 39 | } -------------------------------------------------------------------------------- /benchmarks/benchmarkable/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/benchmarks/benchmarkable/consumer-rules.pro -------------------------------------------------------------------------------- /benchmarks/benchmarkable/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 -------------------------------------------------------------------------------- /benchmarks/benchmarkable/src/androidTest/java/com/example/benchmarks/BenchmarkRunTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.benchmarks 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import com.example.benchmarks.data.PAGE_TO_SCROLL_TO 5 | import com.example.benchmarks.data.PagingBenchmark 6 | import com.example.benchmarks.data.TilingBenchmark 7 | import com.example.benchmarks.data.emptyPages 8 | import com.example.benchmarks.data.offScreenPages 9 | import com.example.benchmarks.data.onScreenPages 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.Assert.* 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | 15 | /** 16 | * Instrumented test, which will execute on an Android device. 17 | * 18 | * See [testing documentation](http://d.android.com/tools/testing). 19 | */ 20 | @RunWith(AndroidJUnit4::class) 21 | class BenchmarkRunTest { 22 | @Test 23 | fun benchmark() = runTest { 24 | repeat(1) { 25 | listOf( 26 | TilingBenchmark( 27 | pageToScrollTo = PAGE_TO_SCROLL_TO, 28 | pagesToInvalidate = emptyPages, 29 | ), 30 | TilingBenchmark( 31 | pageToScrollTo = PAGE_TO_SCROLL_TO, 32 | pagesToInvalidate = offScreenPages, 33 | ), 34 | TilingBenchmark( 35 | pageToScrollTo = PAGE_TO_SCROLL_TO, 36 | pagesToInvalidate = onScreenPages, 37 | ), 38 | PagingBenchmark( 39 | pageToScrollTo = PAGE_TO_SCROLL_TO, 40 | pagesToInvalidate = emptyPages, 41 | ), 42 | PagingBenchmark( 43 | pageToScrollTo = PAGE_TO_SCROLL_TO, 44 | pagesToInvalidate = offScreenPages, 45 | ), 46 | PagingBenchmark( 47 | pageToScrollTo = PAGE_TO_SCROLL_TO, 48 | pagesToInvalidate = onScreenPages, 49 | ), 50 | ).forEach { it.benchmark() } 51 | } 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /benchmarks/benchmarkable/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /benchmarks/benchmarkable/src/main/java/com/example/benchmarks/data/Benchmarked.kt: -------------------------------------------------------------------------------- 1 | package com.example.benchmarks.data 2 | 3 | const val PAGE_TO_SCROLL_TO = 20 4 | const val ITEMS_PER_PAGE = 100 5 | 6 | internal const val NUM_PAGES_IN_MEMORY = 3 7 | internal const val MAX_LOAD_SIZE = NUM_PAGES_IN_MEMORY * ITEMS_PER_PAGE 8 | 9 | @Suppress("EmptyRange") 10 | val emptyPages = 0 until 0 11 | val onScreenPages = (PAGE_TO_SCROLL_TO - NUM_PAGES_IN_MEMORY)..PAGE_TO_SCROLL_TO 12 | val offScreenPages = (onScreenPages.first - NUM_PAGES_IN_MEMORY) until onScreenPages.first 13 | 14 | sealed interface Benchmarked { 15 | suspend fun benchmark() 16 | } 17 | 18 | data class Item( 19 | val index: Int, 20 | val lastInvalidatedPage: Int = Int.MIN_VALUE 21 | ) 22 | 23 | fun rangeFor(startPage: Int, numberOfPages: Int = 1): IntRange { 24 | val offset = startPage * ITEMS_PER_PAGE 25 | val next = offset + (ITEMS_PER_PAGE * numberOfPages) 26 | 27 | return offset until next 28 | } 29 | 30 | fun pageFor(item: Item): Int { 31 | val diff = item.index % ITEMS_PER_PAGE 32 | val firstItemIndex = item.index - diff 33 | return firstItemIndex / ITEMS_PER_PAGE 34 | } 35 | -------------------------------------------------------------------------------- /benchmarks/benchmarkable/src/main/java/com/example/benchmarks/data/Tiling.kt: -------------------------------------------------------------------------------- 1 | package com.example.benchmarks.data 2 | 3 | import com.tunjid.tiler.PivotRequest 4 | import com.tunjid.tiler.Tile 5 | import com.tunjid.tiler.TiledList 6 | import com.tunjid.tiler.listTiler 7 | import com.tunjid.tiler.queries 8 | import com.tunjid.tiler.toPivotedTileInputs 9 | import com.tunjid.tiler.toTiledList 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.MutableSharedFlow 12 | import kotlinx.coroutines.flow.onStart 13 | import kotlinx.coroutines.flow.transform 14 | import kotlinx.coroutines.flow.transformWhile 15 | 16 | private val pivotRequest = PivotRequest( 17 | onCount = NUM_PAGES_IN_MEMORY, 18 | offCount = 0, 19 | comparator = Int::compareTo, 20 | nextQuery = Int::next, 21 | previousQuery = Int::prev, 22 | ) 23 | 24 | private fun Int.next() = this + 1 25 | 26 | private fun Int.prev() = if (this <= 0) null else this - 1 27 | 28 | class TilingBenchmark( 29 | private val pageToScrollTo: Int, 30 | private val pagesToInvalidate: IntRange 31 | ) : Benchmarked { 32 | 33 | private var lastInvalidatedPage: Int = pagesToInvalidate.first + 1 34 | 35 | override suspend fun benchmark() { 36 | val offsetFlow = MutableSharedFlow() 37 | val invalidationSignal = MutableSharedFlow() 38 | 39 | offsetFlow 40 | .onStart { emit(0) } 41 | .toPivotedTileInputs(pivotRequest) 42 | .toTiledList(invalidationSignal.listTiler()) 43 | .transformWhile { latestItems -> 44 | // Wait for items to finish loading 45 | if (latestItems.size != MAX_LOAD_SIZE) return@transformWhile true 46 | 47 | val firstPage = latestItems.queryAt(0) 48 | val lastPage = latestItems.queryAt(latestItems.lastIndex) 49 | val isOnLastPage = lastPage >= pageToScrollTo 50 | 51 | // Trigger loading more 52 | if (!isOnLastPage) { 53 | offsetFlow.emit(lastPage) 54 | return@transformWhile true 55 | } 56 | 57 | // Currently at the page scrolled to. If there's nothing to invalidate, complete 58 | if (pagesToInvalidate.isEmpty()) return@transformWhile false 59 | 60 | // ** Invalidation code ** // 61 | 62 | // Outside the visible range, nothing to invalidate so terminate 63 | if (pagesToInvalidate.first > lastPage) return@transformWhile false 64 | if (pagesToInvalidate.last < firstPage) return@transformWhile false 65 | 66 | println(latestItems.queries()) 67 | // Find an item from the page that was invalidated 68 | val invalidatedItem = latestItems.lastInvalidatedItem() 69 | 70 | val isFinished = invalidatedItem != null 71 | && invalidatedItem.lastInvalidatedPage >= pagesToInvalidate.last 72 | 73 | emit(latestItems) 74 | !isFinished 75 | } 76 | .collect { 77 | // Invalidate 78 | val invalidatedItem = it.lastInvalidatedItem() 79 | val canIncrementAndInvalidate = invalidatedItem == null 80 | || invalidatedItem.lastInvalidatedPage == lastInvalidatedPage 81 | 82 | if (canIncrementAndInvalidate && ++lastInvalidatedPage <= pagesToInvalidate.last) { 83 | invalidationSignal.emit(lastInvalidatedPage) 84 | } 85 | } 86 | } 87 | 88 | private fun TiledList.lastInvalidatedItem(): Item? { 89 | for (item in this) { 90 | if (item.lastInvalidatedPage == lastInvalidatedPage) return item 91 | } 92 | return null 93 | } 94 | } 95 | 96 | private fun Flow.listTiler() = listTiler( 97 | limiter = Tile.Limiter( 98 | maxQueries = NUM_PAGES_IN_MEMORY, 99 | ), 100 | order = Tile.Order.PivotSorted( 101 | query = 0, 102 | comparator = Int::compareTo, 103 | ), 104 | fetcher = { page -> 105 | val range = rangeFor(page) 106 | transform { invalidatedPage -> 107 | if (invalidatedPage == page) emit( 108 | range.map { 109 | Item( 110 | index = it, 111 | lastInvalidatedPage = invalidatedPage 112 | ) 113 | } 114 | ) 115 | } 116 | .onStart { 117 | emit(range.map { 118 | Item( 119 | index = it, 120 | lastInvalidatedPage = Int.MIN_VALUE 121 | ) 122 | }) 123 | } 124 | }, 125 | ) -------------------------------------------------------------------------------- /benchmarks/benchmarkable/src/test/java/com/example/benchmarks/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.benchmarks 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.example.benchmarks.data.PAGE_TO_SCROLL_TO 6 | import com.example.benchmarks.data.Item 7 | import com.example.benchmarks.data.ItemDao 8 | import com.example.benchmarks.data.PagingBenchmarks 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.flowOf 11 | import kotlinx.coroutines.test.runTest 12 | import org.junit.Test 13 | 14 | import org.junit.Assert.* 15 | 16 | /** 17 | * Example local unit test, which will execute on the development machine (host). 18 | * 19 | * See [testing documentation](http://d.android.com/tools/testing). 20 | */ 21 | class ExampleUnitTest { 22 | @Test 23 | fun useAppContext() = runTest { 24 | // TilingBenchmarks(Dao).scrollToIndex(ITEMS_TO_LOAD).run { 25 | // val at = indexOfFirst { it.id == ITEMS_TO_LOAD } 26 | // println("Tiling. id of $ITEMS_TO_LOAD at $at. Size: $size") 27 | // println(joinToString(separator = "\n")) 28 | // } 29 | 30 | PagingBenchmarks(Dao).scrollToIndex(PAGE_TO_SCROLL_TO).run { 31 | val at = indexOfFirst { it.id == PAGE_TO_SCROLL_TO } 32 | println("Tiling. id of $PAGE_TO_SCROLL_TO at $at. Size: $size") 33 | println(joinToString(separator = "\n")) 34 | } 35 | } 36 | } 37 | 38 | private object Dao : ItemDao { 39 | override fun itemPagingSource(): PagingSource = object : PagingSource() { 40 | override fun getRefreshKey(state: PagingState): Int? = 41 | state.anchorPosition 42 | 43 | override suspend fun load( 44 | params: LoadParams 45 | ): LoadResult = LoadResult.Page( 46 | data = params.key?.let { (it until (it + params.loadSize)).map(::Item) } ?: emptyList(), 47 | nextKey = params.key?.let { it + 1 }, 48 | prevKey = params.key?.let { 49 | if (it <= 0) null 50 | else it - 1 51 | }, 52 | ) 53 | } 54 | 55 | override fun items(offset: Int, limit: Int): Flow> = 56 | flowOf((offset until (offset + limit)).map(::Item)) 57 | 58 | 59 | override suspend fun upsertItems(items: List) { 60 | TODO("Not yet implemented") 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /benchmarks/microbenchmark/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /benchmarks/microbenchmark/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("kotlin-android") 4 | id("androidx.benchmark") 5 | id("android-library-convention") 6 | } 7 | 8 | kotlin { 9 | jvmToolchain(17) 10 | } 11 | android { 12 | namespace = "com.example.microbenchmark" 13 | 14 | defaultConfig { 15 | testInstrumentationRunnerArguments["androidx.benchmark.profiling.mode"] = "StackSampling" 16 | testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR,LOW-BATTERY" 17 | } 18 | 19 | testBuildType = "release" 20 | 21 | buildFeatures { 22 | compose = false 23 | } 24 | buildTypes { 25 | getByName("release") { 26 | // The androidx.benchmark plugin configures release buildType with proper settings, such as: 27 | // - disables code coverage 28 | // - adds CPU clock locking task 29 | // - signs release buildType with debug signing config 30 | // - copies benchmark results into build/outputs/connected_android_test_additional_output folder 31 | } 32 | } 33 | } 34 | 35 | dependencies { 36 | androidTestImplementation(project(":benchmarks:benchmarkable")) 37 | androidTestImplementation(libs.androidx.benchmark.junit) 38 | androidTestImplementation(libs.androidx.benchmark.macro) 39 | androidTestImplementation(libs.androidx.test.junit) 40 | 41 | 42 | androidTestImplementation(libs.kotlinx.coroutines.core) 43 | androidTestImplementation(libs.kotlinx.coroutines.test) 44 | } 45 | -------------------------------------------------------------------------------- /benchmarks/microbenchmark/src/androidTest/java/com/example/microbenchmark/AllocationBenchmark.kt: -------------------------------------------------------------------------------- 1 | package com.example.microbenchmark 2 | 3 | import androidx.benchmark.junit4.BenchmarkRule 4 | import androidx.benchmark.junit4.measureRepeated 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.example.benchmarks.data.PAGE_TO_SCROLL_TO 7 | import com.example.benchmarks.data.PagingBenchmark 8 | import com.example.benchmarks.data.TilingBenchmark 9 | import com.example.benchmarks.data.emptyPages 10 | import com.example.benchmarks.data.offScreenPages 11 | import com.example.benchmarks.data.onScreenPages 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.runBlocking 14 | import org.junit.FixMethodOrder 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.junit.runners.MethodSorters 19 | 20 | /** 21 | * This is an example startup benchmark. 22 | * 23 | */ 24 | @ExperimentalCoroutinesApi 25 | @RunWith(AndroidJUnit4::class) 26 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 27 | class AllocationBenchmark { 28 | @get:Rule 29 | val benchmarkRule = BenchmarkRule() 30 | 31 | @Test 32 | fun a_pagingScroll() = benchmarkRule.measureRepeated { 33 | runBlocking { 34 | PagingBenchmark( 35 | pageToScrollTo = PAGE_TO_SCROLL_TO, 36 | pagesToInvalidate = emptyPages 37 | ).benchmark() 38 | } 39 | } 40 | 41 | @Test 42 | fun b_tilingScroll() = benchmarkRule.measureRepeated { 43 | runBlocking { 44 | TilingBenchmark( 45 | pageToScrollTo = PAGE_TO_SCROLL_TO, 46 | pagesToInvalidate = emptyPages 47 | ).benchmark() 48 | } 49 | } 50 | 51 | @Test 52 | fun c_pagingInvalidationOffScreen() = benchmarkRule.measureRepeated { 53 | runBlocking { 54 | PagingBenchmark( 55 | pageToScrollTo = PAGE_TO_SCROLL_TO, 56 | pagesToInvalidate = offScreenPages 57 | ).benchmark() 58 | } 59 | } 60 | 61 | @Test 62 | fun d_tilingInvalidationOffScreen() = benchmarkRule.measureRepeated { 63 | runBlocking { 64 | TilingBenchmark( 65 | pageToScrollTo = PAGE_TO_SCROLL_TO, 66 | pagesToInvalidate = offScreenPages 67 | ).benchmark() 68 | } 69 | } 70 | 71 | @Test 72 | fun e_pagingInvalidationOnScreen() = benchmarkRule.measureRepeated { 73 | runBlocking { 74 | PagingBenchmark( 75 | pageToScrollTo = PAGE_TO_SCROLL_TO, 76 | pagesToInvalidate = onScreenPages 77 | ).benchmark() 78 | } 79 | } 80 | 81 | @Test 82 | fun f_tilingInvalidationOnScreen() = benchmarkRule.measureRepeated { 83 | runBlocking { 84 | TilingBenchmark( 85 | pageToScrollTo = PAGE_TO_SCROLL_TO, 86 | pagesToInvalidate = onScreenPages 87 | ).benchmark() 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /benchmarks/microbenchmark/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build-logic/convention/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 18 | 19 | 20 | plugins { 21 | `kotlin-dsl` 22 | } 23 | 24 | group = "com.tunjid.tiler.buildlogic" 25 | 26 | java { 27 | sourceCompatibility = JavaVersion.VERSION_17 28 | targetCompatibility = JavaVersion.VERSION_17 29 | } 30 | 31 | kotlin { 32 | compilerOptions { 33 | jvmTarget.set(JvmTarget.JVM_17) 34 | } 35 | } 36 | 37 | dependencies { 38 | implementation(libs.jetbrains.compose.gradlePlugin) 39 | implementation(libs.kotlin.gradlePlugin) 40 | implementation(libs.android.gradlePlugin) 41 | implementation(libs.compose.compiler.plugin) 42 | implementation(libs.dokka.gradlePlugin) 43 | } 44 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/android-app-library-convention.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import com.android.build.api.dsl.CommonExtension 18 | import org.gradle.api.JavaVersion 19 | import org.gradle.api.artifacts.Configuration 20 | import org.gradle.api.artifacts.VersionCatalogsExtension 21 | 22 | /* 23 | * Copyright 2021 Google LLC 24 | * 25 | * Licensed under the Apache License, Version 2.0 (the "License"); 26 | * you may not use this file except in compliance with the License. 27 | * You may obtain a copy of the License at 28 | * 29 | * https://www.apache.org/licenses/LICENSE-2.0 30 | * 31 | * Unless required by applicable law or agreed to in writing, software 32 | * distributed under the License is distributed on an "AS IS" BASIS, 33 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 34 | * See the License for the specific language governing permissions and 35 | * limitations under the License. 36 | */ 37 | 38 | /** 39 | * Sets common values for Android Applications and Libraries 40 | */ 41 | fun org.gradle.api.Project.androidConfiguration( 42 | extension: CommonExtension<*, *, *, *, *, *> 43 | ) = extension.apply { 44 | namespace = "com.tunjid.tiler.${project.name}" 45 | compileSdk = 36 46 | 47 | defaultConfig { 48 | minSdk = 23 49 | } 50 | 51 | compileOptions { 52 | sourceCompatibility = JavaVersion.VERSION_11 53 | targetCompatibility = JavaVersion.VERSION_11 54 | } 55 | configureKotlinJvm() 56 | } 57 | 58 | val org.gradle.api.Project.versionCatalog 59 | get() = extensions.getByType(VersionCatalogsExtension::class.java) 60 | .named("libs") -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/android-application-convention.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | id("com.android.application") 19 | } 20 | 21 | android { 22 | androidConfiguration(this) 23 | 24 | defaultConfig { 25 | targetSdk = 33 26 | } 27 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/android-library-convention.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | id("com.android.library") 19 | } 20 | 21 | android { 22 | androidConfiguration(this) 23 | 24 | sourceSets { 25 | named("main") { 26 | // Pull Android manifest from src/androidMain in multiplatform dirs 27 | if (file("src/androidMain").exists()) { 28 | manifest.srcFile("src/androidMain/AndroidManifest.xml") 29 | res.srcDirs("src/androidMain/res") 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/kotlin-jvm-convention.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* 18 | * Copyright 2021 Google LLC 19 | * 20 | * Licensed under the Apache License, Version 2.0 (the "License"); 21 | * you may not use this file except in compliance with the License. 22 | * You may obtain a copy of the License at 23 | * 24 | * https://www.apache.org/licenses/LICENSE-2.0 25 | * 26 | * Unless required by applicable law or agreed to in writing, software 27 | * distributed under the License is distributed on an "AS IS" BASIS, 28 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 29 | * See the License for the specific language governing permissions and 30 | * limitations under the License. 31 | */ 32 | 33 | plugins { 34 | kotlin("multiplatform") 35 | } 36 | 37 | kotlin { 38 | configureKotlinJvm() 39 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.gradle.api.Action 18 | import org.gradle.api.JavaVersion 19 | import org.gradle.api.plugins.JavaPluginExtension 20 | import org.gradle.kotlin.dsl.configure 21 | import org.gradle.kotlin.dsl.withType 22 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 23 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 24 | 25 | /* 26 | * Copyright 2021 Google LLC 27 | * 28 | * Licensed under the Apache License, Version 2.0 (the "License"); 29 | * you may not use this file except in compliance with the License. 30 | * You may obtain a copy of the License at 31 | * 32 | * https://www.apache.org/licenses/LICENSE-2.0 33 | * 34 | * Unless required by applicable law or agreed to in writing, software 35 | * distributed under the License is distributed on an "AS IS" BASIS, 36 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 37 | * See the License for the specific language governing permissions and 38 | * limitations under the License. 39 | */ 40 | 41 | /** 42 | * Configure base Kotlin options for JVM (non-Android) 43 | */ 44 | internal fun org.gradle.api.Project.configureKotlinJvm() { 45 | extensions.configure { 46 | // Up to Java 11 APIs are available through desugaring 47 | // https://developer.android.com/studio/write/java11-minimal-support-table 48 | sourceCompatibility = JavaVersion.VERSION_11 49 | targetCompatibility = JavaVersion.VERSION_11 50 | } 51 | configureKotlin() 52 | } 53 | 54 | /** 55 | * Configure base Kotlin options 56 | */ 57 | private fun org.gradle.api.Project.configureKotlin() { 58 | // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 59 | tasks.withType().configureEach { 60 | compilerOptions { 61 | // Set JVM target to 11 62 | jvmTarget.set(JvmTarget.JVM_11) 63 | freeCompilerArgs.set( 64 | freeCompilerArgs.get() + listOf( 65 | "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", 66 | "-opt-in=androidx.compose.material.ExperimentalMaterialApi", 67 | "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", 68 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", 69 | "-opt-in=kotlinx.coroutines.FlowPreview" 70 | ) 71 | ) 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget 18 | import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension 19 | 20 | /* 21 | * Copyright 2021 Google LLC 22 | * 23 | * Licensed under the Apache License, Version 2.0 (the "License"); 24 | * you may not use this file except in compliance with the License. 25 | * You may obtain a copy of the License at 26 | * 27 | * https://www.apache.org/licenses/LICENSE-2.0 28 | * 29 | * Unless required by applicable law or agreed to in writing, software 30 | * distributed under the License is distributed on an "AS IS" BASIS, 31 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 32 | * See the License for the specific language governing permissions and 33 | * limitations under the License. 34 | */ 35 | 36 | plugins { 37 | kotlin("multiplatform") 38 | } 39 | 40 | kotlin { 41 | applyDefaultHierarchyTemplate() 42 | androidTarget { 43 | publishLibraryVariants("release") 44 | } 45 | jvm { 46 | testRuns["test"].executionTask.configure { 47 | useJUnit() 48 | } 49 | } 50 | listOf( 51 | iosX64(), 52 | iosArm64(), 53 | iosSimulatorArm64(), 54 | ).forEach { iosTarget -> 55 | iosTarget.binaries.framework { 56 | baseName = project.name 57 | isStatic = true 58 | } 59 | } 60 | sourceSets { 61 | all { 62 | languageSettings.apply { 63 | optIn("androidx.compose.animation.ExperimentalAnimationApi") 64 | optIn("androidx.compose.foundation.ExperimentalFoundationApi") 65 | optIn("androidx.compose.material.ExperimentalMaterialApi") 66 | optIn("androidx.compose.ui.ExperimentalComposeUiApi") 67 | optIn("kotlinx.serialization.ExperimentalSerializationApi") 68 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 69 | optIn("kotlinx.coroutines.FlowPreview") 70 | } 71 | } 72 | } 73 | configureKotlinJvm() 74 | } 75 | -------------------------------------------------------------------------------- /build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | `maven-publish` 19 | signing 20 | id("org.jetbrains.dokka") 21 | } 22 | 23 | allprojects { 24 | val versionKey = project.name + "_version" 25 | val libProps = rootProject.ext.get("libProps") as? java.util.Properties 26 | ?: return@allprojects 27 | group = libProps["groupId"] as String 28 | version = libProps[versionKey] as String 29 | 30 | task("printProjectVersion") { 31 | doLast { 32 | println(">> " + project.name + " version is " + version) 33 | } 34 | } 35 | } 36 | 37 | val dokkaHtml by tasks.getting(org.jetbrains.dokka.gradle.DokkaTask::class) { 38 | dokkaSourceSets { 39 | try { 40 | named("iosTest") { 41 | suppress.set(true) 42 | } 43 | } catch (e: Exception) { 44 | } 45 | } 46 | } 47 | 48 | val javadocJar: TaskProvider by tasks.registering(Jar::class) { 49 | dependsOn(dokkaHtml) 50 | archiveClassifier.set("javadoc") 51 | from(dokkaHtml.outputDirectory) 52 | } 53 | 54 | publishing { 55 | publications { 56 | withType { 57 | artifact(javadocJar) 58 | pom { 59 | name.set(project.name) 60 | description.set("An experimental kotlin multiplatform paging library for loading reactive data in chunks") 61 | url.set("https://github.com/tunjid/tiler") 62 | licenses { 63 | license { 64 | name.set("Apache License 2.0") 65 | url.set("https://github.com/tunjid/tiler/blob/main/LICENSE") 66 | } 67 | } 68 | developers { 69 | developer { 70 | id.set("tunjid") 71 | name.set("Adetunji Dahunsi") 72 | email.set("tjdah100@gmail.com") 73 | } 74 | } 75 | scm { 76 | connection.set("scm:git:github.com/tunjid/tiler.git") 77 | developerConnection.set("scm:git:ssh://github.com/tunjid/tiler.git") 78 | url.set("https://github.com/tunjid/tiler/tree/main") 79 | } 80 | } 81 | } 82 | } 83 | repositories { 84 | val localProperties = rootProject.ext.get("localProps") as? java.util.Properties 85 | ?: return@repositories 86 | 87 | val publishUrl = localProperties.getProperty("publishUrl") 88 | if (publishUrl != null) { 89 | maven { 90 | name = localProperties.getProperty("repoName") 91 | url = uri(localProperties.getProperty("publishUrl")) 92 | credentials { 93 | username = localProperties.getProperty("username") 94 | password = localProperties.getProperty("password") 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | 102 | signing { 103 | val localProperties = rootProject.ext.get("localProps") as? java.util.Properties 104 | ?: return@signing 105 | 106 | val signingKey = localProperties.getProperty("signingKey") 107 | val signingPassword = localProperties.getProperty("signingPassword") 108 | 109 | if (signingKey != null && signingPassword != null) { 110 | useInMemoryPgpKeys(signingKey, signingPassword) 111 | sign(publishing.publications) 112 | } 113 | } 114 | 115 | val signingTasks = tasks.withType() 116 | tasks.withType().configureEach { 117 | dependsOn(signingTasks) 118 | } -------------------------------------------------------------------------------- /build-logic/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 18 | 19 | dependencyResolutionManagement { 20 | repositories { 21 | gradlePluginPortal() 22 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 23 | mavenCentral() 24 | google() 25 | } 26 | versionCatalogs { 27 | create("libs") { 28 | from(files("../gradle/libs.versions.toml")) 29 | } 30 | } 31 | } 32 | 33 | rootProject.name = "build-logic" 34 | include(":convention") 35 | -------------------------------------------------------------------------------- /build-logic/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/build-logic/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /build-logic/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | #Mon Jul 05 07:23:39 EDT 2021 18 | distributionBase=GRADLE_USER_HOME 19 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 20 | distributionPath=wrapper/dists 21 | zipStorePath=wrapper/dists 22 | zipStoreBase=GRADLE_USER_HOME 23 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | buildscript { 18 | extra.apply { 19 | set("localProps", java.util.Properties().apply { 20 | file("local.properties").let { file -> 21 | if (file.exists()) load(java.io.FileInputStream(file)) 22 | } 23 | }) 24 | set("libProps", java.util.Properties().apply { 25 | file("libraryVersion.properties").let { file -> 26 | if (file.exists()) load(java.io.FileInputStream(file)) 27 | } 28 | }) 29 | } 30 | repositories { 31 | google() 32 | mavenCentral() 33 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 34 | maven("https://plugins.gradle.org/m2/") 35 | } 36 | } 37 | 38 | plugins { 39 | alias(libs.plugins.android.application) apply false 40 | alias(libs.plugins.android.library) apply false 41 | alias(libs.plugins.androidx.benchmark) apply false 42 | alias(libs.plugins.compose.compiler) apply false 43 | alias(libs.plugins.jetbrains.compose) apply false 44 | alias(libs.plugins.jetbrains.dokka) apply false 45 | alias(libs.plugins.kotlin.android) apply false 46 | alias(libs.plugins.kotlin.multiplatform) apply false 47 | } -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/images/adaptive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/adaptive.gif -------------------------------------------------------------------------------- /docs/images/basic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/basic.gif -------------------------------------------------------------------------------- /docs/images/complex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/complex.gif -------------------------------------------------------------------------------- /docs/images/placeholders.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/placeholders.gif -------------------------------------------------------------------------------- /docs/images/search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/search.gif -------------------------------------------------------------------------------- /docs/images/sticky.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/docs/images/sticky.gif -------------------------------------------------------------------------------- /docs/implementation/performance.md: -------------------------------------------------------------------------------- 1 | # Efficiency & performance 2 | 3 | ## General Tiling 4 | 5 | As tiling loads from multiple flows simultaneously, performance is a function of 2 things: 6 | 7 | * How often the backing `Flow` for each `Input.Request` emits 8 | * The time and space complexity of the transformations applied to the 9 | output `TiledList`. 10 | 11 | In the case of a former, the `Flow` should only emit if the backing dataset has actually changed. 12 | This prevents unnecessary emissions downstream. 13 | 14 | In the case of the latter, use `PivotRequest(on = x)` and `Input.Limiter` to match the 15 | output `TiledList` to the size of the view port of the user's device to create an efficient 16 | paging pipeline. 17 | 18 | For example if tiling is done for the UI, with a viewport that can display 20 items at once: 19 | 20 | * 20 items can be fetched per page 21 | * 100 items (20 * 5 pages) can be observed at concurrently 22 | * `Input.Limiter.List(maxQueries = 3)` can be set so only changes to the visible 60 items will be 23 | sent to the UI at once. 24 | 25 | The items can be transformed with algorithms of `O(N)` to `O(N^2)` time and space complexity 26 | trivially as regardless of the size of the actual paginated set, only 60 items will be transformed 27 | at any one time. 28 | 29 | ## MutableTiledList 30 | 31 | The performance of each method for the default `MutableTiledList` implementation is 32 | comparable to the cost of the same method in an `ArrayList` + O(log(T)) 33 | where T is the number of `Tile` instances (queries/pages) in the `TiledList`. 34 | This makes them perfect for use in recycling and scrolling containers. 35 | -------------------------------------------------------------------------------- /docs/implementation/pivoted-tiling.md: -------------------------------------------------------------------------------- 1 | ## How to page with Tiling 2 | 3 | While the tiling API lets you assemble a paging pipeline from scratch using its primitives, the 4 | easiest scalable 5 | pagination approach with tiling is through the pivoting algorithm. 6 | 7 | Consider a large, possibly infinite set of paginated data where a user is currently viewing page p, 8 | and n 9 | is the buffer zone - the number of pages lazy loaded in case the user wants to visit it. 10 | 11 | ``` 12 | [..., p - n, ..., p - 1, p, p + 1, ..., p + n, ...] 13 | ``` 14 | 15 | As the user moves from page to page, items can be refreshed around the user's current page 16 | while allowing them to observe immediate changes to the data they're looking at. 17 | 18 | This is expanded in the diagram below: 19 | 20 | ``` 21 | [out of bounds] -> Evict from memory 22 | _ 23 | [p - n - 1 - n] | 24 | ... | -> Keep pages in memory, but don't observe 25 | [p - n - 1] _ _| 26 | [p - n] | 27 | ... | 28 | [p - 1] | 29 | [p] | -> Observe pages 30 | [p + 1] | 31 | ... | 32 | [p + n] _| _ 33 | [p + n + 1] | 34 | ... | -> Keep pages in memory, but don't observe 35 | [p + n + 1 + n] _| 36 | 37 | [out of bounds] -> Evict from memory 38 | ``` 39 | 40 | `n` is an arbitrary number that may be defined by how many items are visible on the screen at once. 41 | It could be fixed, 42 | or variable depending on conditions like the available screen real estate. 43 | 44 | For an example where `n` is a function of grid size in a grid list, check 45 | out [ArchiveLoading.kt](https://github.com/tunjid/me/blob/main/common/feature-archive-list/src/commonMain/kotlin/com/tunjid/me/feature/archivelist/ArchiveLoading.kt) 46 | in the [me](https://github.com/tunjid/me) project. 47 | 48 | The above algorithm is called "pivoting" as items displayed are pivoted around the user's current 49 | scrolling position. 50 | 51 | Since tiling is dynamic at it's core, a pipeline can be built to allow for this dynamic behavior by 52 | pivoting around the user's current position with the grid size as a dynamic input parameter. -------------------------------------------------------------------------------- /docs/implementation/primitives.md: -------------------------------------------------------------------------------- 1 | ## API surface and Tiling primitives 2 | 3 | Given a `Flow` of `Tile.Input`, tiling transforms them into a `Flow>` with 4 | a `ListTiler`. 5 | 6 | The resulting `TiledList` should be kept at a size that covers approximately 3 times the viewport. 7 | This is typically at or under 100 items for non grid UIs. You can then transform this list however 8 | way you want. 9 | 10 | ## Managing requested data 11 | 12 | A tiled pagination pipeline is managed by the `Tile.Input` it receives. These inputs drive the 13 | dynamism of the pipeline. The following is a breakdown of them all. 14 | 15 | ### `Input.Request` 16 | 17 | * `Tile.Request.On`: Analogous to `put` for a `Map`, this starts collecting from the backing `Flow` 18 | for the specified `query`. It is 19 | idempotent; multiple requests have no side effects for loading, i.e the same `Flow` will not be 20 | collect twice. 21 | 22 | * `Tile.Request.Off`: Stops collecting from the backing `Flow` for the specified `query`. The items 23 | previously fetched by this query 24 | are still kept in memory and will be in the `TiledList` of items returned. Requesting this is 25 | idempotent; multiple requests 26 | have no side effects. 27 | 28 | * `Tile.Request.Evict`: Analogous to `remove` for a `Map`, this stops collecting from the 29 | backing `Flow` for the specified `query` and 30 | also evicts the items previously fetched by the `query` from memory. Requesting this is 31 | idempotent; multiple requests 32 | have no side effects. 33 | 34 | ### `Tile.Batch` 35 | 36 | Used for dispatching multiple `Tile.Input` instances. The `ListTiler` may emit `TiledList` instances 37 | during the application of a `Tile.Batch` input; it is not transactional. Rather, it is an 38 | encapsulation of an aggregate of `Tile.Input` that represents a single logical operation. Users of 39 | the library may also define arbitrary `Tile.Batch` instances and use them in their tiled pipelines. 40 | 41 | ### `Pivot` 42 | 43 | An implementation of `Tile.Batch`, this allows for returning a `TiledList` from results 44 | around a particular `Query`. It's use must be accompanied by a `Tile.Order.PivotSorted`. 45 | 46 | ### `Tile.Input.Limiter` 47 | 48 | Can be used to select a subset of items tiled instead of the entire paginated set. For example, 49 | assuming 1000 items have been 50 | fetched, there's no need to send a 1000 items to the UI for diffing/display when the UI can only 51 | show about 30 at once. 52 | The `Limiter` allows for selecting an arbitrary amount of items as the situation demands. 53 | 54 | ### `Tile.Input.Order` 55 | 56 | Defines the heuristic for selecting tiled items into the output `TiledList`. 57 | 58 | * Sorted: Sort items with a specified query `comparator`. 59 | 60 | * PivotSorted: Sort items with the specified `comparator` but pivoted around a specific `Query`. 61 | This allows for showing items that have more priority over others in the current context 62 | like example in a list being scrolled. In other words assume tiles have been fetched for queries 63 | 1 - 10 but a 64 | user can see pages 5 and 6. The UI need only to be aware of pages 4, 5, 6, and 7. This allows for 65 | a rolling window of 66 | queries based on a user's scroll position. 67 | 68 | -------------------------------------------------------------------------------- /docs/implementation/tiledlist.md: -------------------------------------------------------------------------------- 1 | A `TiledList` is a `List` that: 2 | 3 | * Is a sublist of the items in the backing data source. 4 | * Allows for looking up the query that fetched each item. 5 | 6 | The latter is done by associating a range of indices in the `List` with a `Tile`. 7 | Effectively, a `TiledList` "chunks" its items by query. 8 | For example, the `TiledList` below is a `List` with 10 items, and two tiles. Each `Tile` covers 5 9 | indices: 10 | 11 | ``` 12 | | 1 | 2 | 13 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 14 | ``` 15 | 16 | A `Tile` is a `value` class with the following public properties: 17 | 18 | ```kotlin 19 | value class Tile(...) { 20 | // start index for a chunk 21 | val start: Int 22 | 23 | // end exclusive index for a chunk 24 | val end: Int 25 | } 26 | ``` 27 | 28 | A `TiledList` is defined as: 29 | 30 | ```kotlin 31 | interface TiledList : List { 32 | /** 33 | * The number of [Tile] instances or query ranges there are in this [TiledList] 34 | */ 35 | val tileCount: Int 36 | 37 | /** 38 | * Returns the [Tile] at the specified tile index. 39 | */ 40 | fun tileAt(tileIndex: Int): Tile 41 | 42 | /** 43 | * Returns the query at the specified tile index. 44 | */ 45 | fun queryAtTile(tileIndex: Int): Query 46 | 47 | /** 48 | * Returns the query that fetched an [Item] at a specified index. 49 | */ 50 | fun queryAt(index: Int): Query 51 | } 52 | ``` 53 | 54 | `MutableTiledList` instances also exist: 55 | 56 | ```kotlin 57 | interface MutableTiledList : TiledList { 58 | fun add(index: Int, query: Query, item: Item) 59 | 60 | fun add(query: Query, item: Item): Boolean 61 | 62 | fun addAll(query: Query, items: Collection): Boolean 63 | 64 | fun addAll(index: Int, query: Query, items: Collection): Boolean 65 | 66 | fun remove(index: Int): Item 67 | } 68 | ``` 69 | 70 | This is useful for modifying `TiledList` instances returned. Actions like: 71 | 72 | * Inserting separators or other interstitial content 73 | * Mapping items with in memory data after fetching from a database 74 | * General list modification 75 | 76 | can be easily performed. -------------------------------------------------------------------------------- /docs/usecases/adaptive-paging.md: -------------------------------------------------------------------------------- 1 | The following guide helps create the UI/UX seen below: 2 | 3 |

4 | Adaptive 5 |

6 | 7 | ## Guide 8 | 9 | Situations can arise that can require more dynamic pagination. 10 | Consider paging for an adaptive layout. The items fetched can be a function of: 11 | 12 | * A UI that can change in size, requiring more items to fit the view port. 13 | * A user wanting to change the sort order. 14 | 15 | A pivoting pipeline for the above looks like: 16 | 17 | ```kotlin 18 | 19 | // Query for items describing the page and sort order 20 | data class PageQuery( 21 | val page: Int, 22 | val isAscending: Boolean 23 | ) 24 | 25 | class Loader( 26 | isDark: Boolean, 27 | scope: CoroutineScope 28 | ) { 29 | // Current query that is visible in the view port 30 | private val currentQuery = MutableStateFlow( 31 | PageQuery( 32 | page = 0, 33 | isAscending = true 34 | ) 35 | ) 36 | 37 | // Number of columns in the grid 38 | private val numberOfColumns = MutableStateFlow(1) 39 | 40 | // Flow specifying the pivot configuration 41 | private val pivotRequests = combine( 42 | currentQuery.map { it.isAscending }, 43 | numberOfColumns, 44 | ::pivotRequest 45 | ).distinctUntilChanged() 46 | 47 | // Define inputs that match the current pivoted position 48 | private val pivotInputs = currentQuery.toPivotedTileInputs( 49 | pivotRequests = pivotRequests 50 | ) 51 | 52 | // Allows for changing the order on response to user input 53 | private val orderInputs = currentQuery 54 | .map { pageQuery -> 55 | Tile.Order.PivotSorted( 56 | query = pageQuery, 57 | comparator = when { 58 | pageQuery.isAscending -> ascendingPageComparator 59 | else -> descendingPageComparator 60 | } 61 | ) 62 | } 63 | .distinctUntilChanged() 64 | 65 | // Change limit to account for dynamic view port size 66 | private val limitInputs = numberOfColumns.map { gridSize -> 67 | Tile.Limiter { items -> items.size > MIN_ITEMS_TO_SHOW * gridSize } 68 | } 69 | 70 | val tiledList: Flow> = merge( 71 | pivotInputs, 72 | orderInputs, 73 | limitInputs, 74 | ) 75 | .toTiledList( 76 | numberTiler( 77 | itemsPerPage = ITEMS_PER_PAGE, 78 | isDark = isDark, 79 | ) 80 | ) 81 | 82 | fun setCurrentPage(page: Int) = currentQuery.update { query -> 83 | query.copy(page = page) 84 | } 85 | 86 | fun toggleOrder() = currentQuery.update { query -> 87 | query.copy(isAscending = !query.isAscending) 88 | } 89 | 90 | fun setNumberOfColumns(numberOfColumns: Int) = this.numberOfColumns.update { 91 | numberOfColumns 92 | } 93 | 94 | // Avoid breaking object equality in [PivotRequest] by using vals 95 | private val nextQuery: PageQuery.() -> PageQuery? = { 96 | copy(page = page + 1) 97 | } 98 | private val previousQuery: PageQuery.() -> PageQuery? = { 99 | copy(page = page - 1).takeIf { it.page >= 0 } 100 | } 101 | 102 | /** 103 | * Pivoted tiling with the grid size as a dynamic input parameter 104 | */ 105 | private fun pivotRequest( 106 | isAscending: Boolean, 107 | numberOfColumns: Int, 108 | ) = PivotRequest( 109 | onCount = 4 * numberOfColumns, 110 | offCount = 4 * numberOfColumns, 111 | nextQuery = nextQuery, 112 | previousQuery = previousQuery, 113 | comparator = when { 114 | isAscending -> ascendingPageComparator 115 | else -> descendingPageComparator 116 | } 117 | ) 118 | } 119 | 120 | private fun numberTiler( 121 | itemsPerPage: Int, 122 | isDark: Boolean, 123 | ): ListTiler = 124 | listTiler( 125 | limiter = Tile.Limiter { items -> items.size > 40 }, 126 | order = Tile.Order.PivotSorted( 127 | query = PageQuery(page = 0, isAscending = true), 128 | comparator = ascendingPageComparator 129 | ), 130 | fetcher = { pageQuery -> 131 | pageQuery.colorShiftingTiles(itemsPerPage, isDark) 132 | } 133 | ) 134 | 135 | fun PageQuery.colorShiftingTiles(itemsPerPage: Int, isDark: Boolean): Flow> { 136 | ... 137 | } 138 | ``` 139 | 140 | In the above, only flows for 4 * numOfColumns queries are collected at any one time. 4 * numOfColumns more queries are kept in memory for quick 141 | resumption, and the rest are evicted from memory. As the user scrolls, `setCurrentPage` is called, and data is 142 | fetched for that page, and the surrounding pages. 143 | Pages that are far away from the defined range are removed from memory. -------------------------------------------------------------------------------- /docs/usecases/basic-example.md: -------------------------------------------------------------------------------- 1 | # Basic example 2 | 3 | The following guide should help create the UI/UX seen below: 4 | 5 |

6 | Basic 7 |

8 | 9 | ## Guide 10 | 11 | Imagine a social media feed app backed by a `FeedRepository`. 12 | Each page in the repository returns 30 items. A pivoted tiling pipeline for it can be assembled as 13 | follows: 14 | 15 | ```kotlin 16 | class FeedState( 17 | repository: FeedRepository 18 | ) { 19 | private val requests = MutableStateFlow(0) 20 | 21 | private val comparator = Comparator(Int::compareTo) 22 | 23 | // A TiledList is a regular List that has information about what 24 | // query fetched an item at each index 25 | val feed: StateFlow> = requests 26 | .toPivotedTileInputs( 27 | PivotRequest( 28 | // 5 pages are fetched concurrently, so 150 items 29 | onCount = 5, 30 | // A buffer of 2 extra pages on either side are kept, so 210 items total 31 | offCount = 2, 32 | comparator = comparator, 33 | nextQuery = { 34 | this + 1 35 | }, 36 | previousQuery = { 37 | (this - 1).takeIf { it >= 0 } 38 | } 39 | ) 40 | ) 41 | .toTiledList( 42 | listTiler( 43 | // Start by pivoting around 0 44 | order = Tile.Order.PivotSorted( 45 | query = 0, 46 | comparator = comparator 47 | ), 48 | // Limit to only 3 pages of data in UI at any one time, so 90 items 49 | limiter = Tile.Limiter( 50 | maxQueries = 3, 51 | itemSizeHint = null, 52 | ), 53 | fetcher = { page -> 54 | // The fetcher returns a flow, this allows for self updating pages 55 | flow { emit(repository.getPage(page)) } 56 | } 57 | ) 58 | ) 59 | .stateIn(/*...*/) 60 | 61 | fun setVisiblePage(page: Int) { 62 | requests.value = page 63 | } 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /docs/usecases/complex-tiling.md: -------------------------------------------------------------------------------- 1 | The following guide should help create the UI/UX seen below: 2 | 3 |

4 | Complex 5 |

6 | 7 | See the `ArchiveList` state production pipeline in 8 | the [me](https://github.com/tunjid/me/blob/main/common/ui/archive-list/src/commonMain/kotlin/com/tunjid/me/feature/archivelist/ArchiveListStateHolder.kt) 9 | github project for an example of a a complex tiled pagination pipeline with key preservation across 10 | multiple queries. In it, the current item the user is viewing can remain anchored even as the 11 | search filter changes. 12 | 13 | ## Guide 14 | 15 | In your application you may have scenarios that are a combination of all the use cases covered: 16 | 17 | * Adapts to different screen sizes 18 | * Uses placeholders 19 | * Implements search 20 | * Requires extra transformations 21 | 22 | In situations like this, the preceding sections still apply. Each use case is 23 | independent by and large. That said, the most difficult issue faced with combined use cases is 24 | key preservation. 25 | 26 | # Key preservation 27 | 28 | In UIs, keys provide unique tokens to represent individual items in lists. This is necessary for 29 | scroll state preservation and animations. In a tiled paging pipeline where items change due to: 30 | 31 | * Placeholders being replaced 32 | * Items being sorted or reordered differently 33 | * Generic items being replaced 34 | * Search queries changing 35 | 36 | The best way to preserve keys across these changes is to maintain a snapshot of the old list, 37 | and when the new list arrives, preserve the keys in the old list in the new list by defining 38 | a way to identify them in the new list. 39 | 40 | ### On each query... 41 | 42 | In a range of items in a query/page that has 20 items: 43 | 44 | * First generate 20 unique ids for all items in that range. 45 | * Create placeholders that use those ids and emit them. 46 | * Asynchronously load and emit new items, and use the same ids for the placeholders in those items. 47 | 48 | ### On each `TiledList` emission... 49 | 50 | * Keep a reference to the current list presented in the UI 51 | * When the new `TiledList` is emitted, compare the old list to the new list 52 | * If the old list has items that are not placeholders that are present in the new list, replace the 53 | ids in the new list with the ids from the old list. 54 | * Make sure ids are not duplicated. 55 | 56 | The steps above will allow you to achieve smooth item animations in complex pagination pipelines. 57 | -------------------------------------------------------------------------------- /docs/usecases/compose.md: -------------------------------------------------------------------------------- 1 | ## Pivoting with Jetpack Compose 2 | 3 | Pivoted tiling in Jetpack Compose is done using the `PivotedTilingEffect`: 4 | 5 | ```kotlin 6 | @Composable 7 | fun Feed( 8 | state: FeedState 9 | ) { 10 | val feed by state.feed.collectAsState() 11 | val lazyState = rememberLazyListState() 12 | 13 | LazyColumn( 14 | state = lazyState, 15 | content = { 16 | items( 17 | items = feed, 18 | key = FeedItem::key, 19 | itemContent = { /*...*/ } 20 | ) 21 | } 22 | ) 23 | 24 | lazyState.PivotedTilingEffect( 25 | items = feed, 26 | // Update the user's current visible query 27 | onQueryChanged = { page -> if (it != null) state.setVisiblePage(page) } 28 | ) 29 | } 30 | ``` 31 | 32 | As the user scrolls, `setVisiblePage` is called to keep pivoting about the current position. 33 | 34 | ## Unique keys 35 | 36 | Tiling collects from each `Flow` for all queries that are on concurrently. For pagination from 37 | a database where items can be inserted, items may be duplicated in the produced `TIledList`. 38 | 39 | For example consider a DB table consisting of tasks sorted by ascending date: 40 | 41 | | id | date | task | 42 | |------|----------|-----------------------| 43 | | ... | | | 44 | | 0998 | 01/01/23 | Go for a jog | 45 | | 0999 | 01/04/23 | Print shipping labels | 46 | | ... | | | 47 | 48 | Assuming 20 items per query, tasks 0980 - 0999 will be contained in a query for page 50. 49 | 50 | Assume a new task for "Check invoices" with id 1000 is entered for date 01/03/23: 51 | 52 | | id | date | task | 53 | |------|----------|-----------------------| 54 | | ... | | | 55 | | 0998 | 01/01/23 | Go for a jog | 56 | | 1000 | 01/03/23 | Check invoices | 57 | | 0999 | 01/04/23 | Print shipping labels | 58 | | ... | | | 59 | 60 | There are now 51 pages of tasks. When page 51 emits: 61 | 62 | * It will contain the last task alone; task 099 "Print shipping labels". 63 | * Page 50 will still have its last emitted tasks 0980 - 0999, including "Print shipping labels". 64 | * At some point in the future, page 50 will update to contain the new task 1000 "Check invoices" and 65 | exclude task 0999 - "Print shipping labels". 66 | 67 | Until page 50 updates, task 0999 "Print shipping labels" will be duplicated in the list. To address 68 | this, the 69 | produced `TiledList` will need to be filtered for duplicates since keys must be unique in Compose 70 | lazy layouts and indices cannot be used for keys without losing animations. 71 | 72 | This is easily done using `TiledList.distinct()` or `TiledList.distinctBy()`. The cost of this fixed 73 | since a `TiledList` is a sublist of the entire collection. Using a pivoted tiling 74 | pipeline where 5 queries are kept on, but 3 queries are presented to the UI at any one 75 | time (using `Tile.Limiter`), the fixed cost for de-duplicating items for every change in the 76 | data set is O(60). 77 | 78 | Note: Page 51 is not guaranteed to have emitted first. Any query can emit at anytime 79 | when tiling. Tiling presents snapshots of the paging pipeline at a single point in time. It is not 80 | opinionated about the data contained. It only guarantees ordering of the queries according to the 81 | `Tile.Order` specified in the tiling configuration. This makes it flexible enough for post 82 | processing of data like filtering, debouncing, mapping and so on. 83 | 84 | ## Sticky headers 85 | 86 | For a `LazyList` in Compose, sticky headers can be implemented using the following: 87 | 88 | ```kotlin 89 | // This ideally would be done in the ViewModel 90 | val grouped = contacts.groupBy { it.firstName[0] } 91 | 92 | @OptIn(ExperimentalFoundationApi::class) 93 | @Composable 94 | fun ContactsList(grouped: Map>) { 95 | LazyColumn { 96 | grouped.forEach { (initial, contactsForInitial) -> 97 | stickyHeader { 98 | CharacterHeader(initial) 99 | } 100 | 101 | items(contactsForInitial) { contact -> 102 | ContactListItem(contact) 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | When paging with a `TiledList`, grouping can still be performed. If you do not need `TiledList` 110 | metadata on the grouped data use `List.groupBy()`, otherwise use `TiledList.groupBy()` which will 111 | return a `Map`. 112 | -------------------------------------------------------------------------------- /docs/usecases/overview.md: -------------------------------------------------------------------------------- 1 | As tiling is a pure function that operates on a reactive stream, its configuration can be changed on 2 | the fly. 3 | This lends it well to the following situations: 4 | 5 | * Offline-first apps: Tiling delivers targeted updates to only queries that have changed. This works 6 | well for 7 | apps which write to the database as the source of truth, and need the UI to update immediately. 8 | For example 9 | a viral tweet whose like count updates several times a second. 10 | 11 | * Adaptive pagination: The amount of items paginated through can be adjusted dynamically to account 12 | for app window 13 | resizing by turning [on](https://github.com/tunjid/Tiler#inputrequest) more pages and increasing 14 | the 15 | [limit](https://github.com/tunjid/Tiler#inputlimiter) of data sent to the UI from the paginated 16 | data available. 17 | An example is in 18 | the [Me](https://github.com/tunjid/me/blob/main/common/feature-archive-list/src/commonMain/kotlin/com/tunjid/me/feature/archivelist/ArchiveLoading.kt) 19 | app. 20 | 21 | * Dynamic sort order: The sort order of paginated items can be changed cheaply on a whim by changing 22 | the 23 | [order](https://github.com/tunjid/Tiler#inputorder) as this only operates on the data output from 24 | the tiler, and not 25 | the entire paginated data set. An example is in the sample in this 26 | [repository](https://github.com/tunjid/Tiler/blob/develop/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/advanced/NumberFetching.kt). 27 | -------------------------------------------------------------------------------- /docs/usecases/placeholders.md: -------------------------------------------------------------------------------- 1 | # Placeholders 2 | 3 | The following guide helps create the UI/UX seen below: 4 | 5 |

6 | Placeholders 7 |

8 | 9 | The code for the above can be seen in the Musify Spotify clone, on the Podcast 10 | episode 11 | detail [screen](https://github.com/tunjid/Musify/blob/main/app/src/main/java/com/example/musify/ui/screens/searchscreen/StateProduction.kt). 12 | 13 | ## Guide 14 | 15 | When loading data from asynchronous sources, it is sometimes required to show static data first. 16 | Since tiling exposes a `List`, inserting placeholders typically involves emitting the placeholder 17 | items first. 18 | 19 | Consider the following repository that fetches a list of podcast episodes from the network with 20 | paginated offsets: 21 | 22 | ```kotlin 23 | 24 | private const val LIMIT = 20 25 | 26 | /** 27 | * A query for tracks at a certain offset 28 | */ 29 | data class PodcastEpisodeQuery( 30 | val offset: Int, 31 | val limit: Int = LIMIT, 32 | ) 33 | 34 | interface PodcastEpisodeRepository { 35 | suspend fun episodesFor(query: PodcastEpisodeQuery): List 36 | } 37 | ``` 38 | 39 | The above can be represented in the UI with a sealed class hierarchy for presentation: 40 | 41 | ```kotlin 42 | sealed class PodcastEpisodeItem { 43 | data class Placeholder( 44 | val key: String, 45 | ) : PodcastEpisodeItem() 46 | 47 | data class Loaded( 48 | val key: String, 49 | val track: Track, 50 | ) : PodcastEpisodeItem() 51 | } 52 | ``` 53 | 54 | The tiling pipeline can then be used to emit placeholders immediately, then the actual items can 55 | then be fetched asynchronously. 56 | 57 | ```kotlin 58 | fun tiledPodcastEpisodes( 59 | startQuery: PodcastEpisodeQuery, 60 | queries: Flow, 61 | repository: PodcastEpisodeRepository, 62 | ): Flow> = queries 63 | .toPivotedTileInputs( 64 | PivotRequest( 65 | onCount = 5, 66 | offCount = 4, 67 | comparator = compareBy(TrackQuery::offset), 68 | nextQuery = { 69 | copy(offset = offset + limit) 70 | }, 71 | previousQuery = { 72 | if (offset == 0) null 73 | else copy(offset = offset - limit) 74 | }, 75 | ) 76 | ) 77 | .toTiledList( 78 | listTiler( 79 | order = Tile.Order.PivotSorted( 80 | query = startQuery, 81 | comparator = compareBy(TrackQuery::offset) 82 | ), 83 | limiter = Tile.Limiter( 84 | maxQueries = 3 85 | ), 86 | fetcher = { query -> 87 | flow { 88 | val keys = (query.offset until (query.offset + query.limit)) 89 | // emit all placeholders first 90 | emit(keys.map(PodcastEpisodeItem::Placeholder)) 91 | // Fetch tracks asynchronously 92 | val episodes = repository.episodesFor(query) 93 | // if the repository returns a `Flow`, `emitAll` can be used instead 94 | emit( 95 | episodes.mapIndexed { index, track -> 96 | PodcastEpisodeItem.Loaded( 97 | // Make sure the loaded items and placeholders share the same keys 98 | key = keys[index], 99 | track = track, 100 | ) 101 | } 102 | ) 103 | } 104 | // A basic retry strategy if the network fetch fails 105 | .retry(retries = 10) { e -> 106 | e.printStackTrace() 107 | // retry on any IOException but also introduce delay if retrying 108 | val shouldRetry = e is IOException 109 | if (shouldRetry) delay(1000) 110 | shouldRetry 111 | } 112 | // If the network is unavailable, nothing may be emitted 113 | .catch { emit(emptyTiledList()) } 114 | } 115 | ) 116 | ) 117 | ``` 118 | -------------------------------------------------------------------------------- /docs/usecases/search.md: -------------------------------------------------------------------------------- 1 | The following guide helps create the UI/UX seen below: 2 | 3 |

4 | Search 5 |

6 | 7 | The code for the above can be seen in the Musify Spotify clone, on the search 8 | [screen](https://github.com/tunjid/Musify/blob/main/app/src/main/java/com/example/musify/ui/screens/podcastshowdetailscreen/StateProduction.kt). 9 | 10 | ## Guide 11 | 12 | Tiling provides data as a continuous stream, so search can be easily implemented without losing 13 | items that were previously fetched by debouncing as the queries change. 14 | 15 | Consider a paginated API that allows that allows for filtering results that matches a query: 16 | 17 | ```kotlin 18 | 19 | private const val LIMIT = 20 20 | 21 | /** 22 | * A query for tracks at a certain offset matching a query 23 | */ 24 | 25 | data class TracksQuery( 26 | val matching: String, 27 | val offset: Int, 28 | val limit: Int = LIMIT, 29 | ) 30 | 31 | interface TracksRepository { 32 | suspend fun tracksFor(query: TracksQuery): List 33 | } 34 | ``` 35 | 36 | Tracks can be fetched by: 37 | 38 | * Debouncing the query to account for user typing 39 | * Debouncing the output when the output `TiledList` is empty or doesn't have all the requested pages available to allow for item add/remove/move animations. 40 | 41 | ```kotlin 42 | fun tiledTracks( 43 | startQuery: TracksQuery, 44 | queries: Flow, 45 | repository: TracksRepository, 46 | ): Flow> = 47 | queries.debounce { 48 | // Don't debounce the if its the first character or more is being loaded 49 | if (it.matching.length < 2 || it.offset != startQuery.offset) 0 50 | // Debounce for key input 51 | else 300 52 | } 53 | .toPivotedTiledInputs( 54 | PivotRequest( 55 | onCount = 5, 56 | offCount = 4, 57 | comparator = compareBy(TrackQuery::offset), 58 | nextQuery = { 59 | copy(offset = offset + limit) 60 | }, 61 | previousQuery = { 62 | if (offset == 0) null 63 | else copy(offset = offset - limit) 64 | }, 65 | ) 66 | ) 67 | .toTiledList( 68 | listTiler( 69 | order = Tile.Order.PivotSorted( 70 | query = startQuery, 71 | comparator = compareBy(TrackQuery::offset) 72 | ), 73 | limiter = Tile.Limiter( 74 | maxQueries = 3 75 | ), 76 | fetcher = { query -> 77 | flow { emit(repository.tracksFor(query)) } 78 | } 79 | ) 80 | ) 81 | .debounce { tiledItems -> 82 | // If empty, or has a few pages of data the search query might have just changed. 83 | // Allow items to be fetched for item position animations 84 | if (tiledItems.isEmpty() || tiledItems.tileCount < 3) 350L 85 | else 0L 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/usecases/transformations.md: -------------------------------------------------------------------------------- 1 | There are extensions on `TiledList` that offer many standard library like transformations 2 | to allow for easy `TiledList` modification. These include: 3 | 4 | * `TiledList.map()` and `TiledList.mapIndexed()` 5 | * `TiledList.filter()`, `TiledList.filterIndexed()` and `TiledList.filterisInstance()` 6 | * `TiledList.distinct()` and `TiledList.distinctBy()` 7 | * `TiledList.groupBy()` 8 | 9 | ## Generic transformations 10 | 11 | For transformations outside of this, a `buildTiledList` method that offers semantics identical to 12 | the Kotlin standard 13 | library [`buildList`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/build-list.html) 14 | is also available. 15 | 16 | This method is most applicable to additive modifications like adding separators, 17 | or other miscellaneous items at arbitrary indices. 18 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | # Project-wide Gradle settings. 18 | # IDE (e.g. Android Studio) users: 19 | # Gradle settings configured through the IDE *will override* 20 | # any settings specified in this file. 21 | # For more details on how to configure your build environment visit 22 | # http://www.gradle.org/docs/current/userguide/build_environment.html 23 | # Specifies the JVM arguments used for the daemon process. 24 | # The setting is particularly useful for tweaking memory settings. 25 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 26 | # When configured, Gradle will run in incubating parallel mode. 27 | # This option should only be used with decoupled projects. More details, visit 28 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 29 | # org.gradle.parallel=true 30 | # AndroidX package structure to make it clearer which packages are bundled with the 31 | # Android operating system, and which are packaged with your app"s APK 32 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 33 | android.useAndroidX=true 34 | # Kotlin code style for this project: "official" or "obsolete": 35 | kotlin.code.style=official 36 | org.jetbrains.compose.experimental.jscanvas.enabled=true 37 | kotlin.mpp.androidSourceSetLayoutVersion=2 38 | xcodeproj=~/sample/ios 39 | 40 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | androidGradlePlugin = "8.9.2" 3 | androidxActivity = "1.10.1" 4 | androidxAppCompat = "1.7.0" 5 | androidxBenchmark = "1.3.4" 6 | androidxCore = "1.16.0" 7 | androidxPaging = "3.3.6" 8 | androidxTestCore = "1.6.1" 9 | androidxTestExt = "1.2.1" 10 | androidxTestRunner = "1.6.2" 11 | androidxTestRules = "1.6.1" 12 | dokka = "1.9.20" 13 | jetbrainsCompose = "1.8.0" 14 | junit4 = "4.13.2" 15 | kotlin = "2.1.20" 16 | kotlinxCoroutines = "1.10.2" 17 | materialIcons = "1.6.11" 18 | googleMaterial = "1.12.0" 19 | tunjidMutator = "1.1.0" 20 | tunjidTreeNav = "0.0.21" 21 | turbine = "1.1.0" 22 | 23 | [libraries] 24 | android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } 25 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } 26 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } 27 | androidx-benchmark-junit = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "androidxBenchmark" } 28 | androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxBenchmark" } 29 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } 30 | androidx-paging = { group = "androidx.paging", name = "paging-runtime", version.ref = "androidxPaging" } 31 | androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } 32 | androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } 33 | androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } 34 | androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } 35 | cashapp-turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } 36 | compose-compiler-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } 37 | google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } 38 | jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } 39 | jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } 40 | jetbrains-compose-foundation-layout = { group = "org.jetbrains.compose.foundation", name = "foundation-layout", version.ref = "jetbrainsCompose" } 41 | jetbrains-compose-gradlePlugin = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version.ref = "jetbrainsCompose" } 42 | jetbrains-compose-material = { group = "org.jetbrains.compose.material", name = "material", version.ref = "jetbrainsCompose" } 43 | jetbrains-compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "jetbrainsCompose" } 44 | jetbrains-compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4", version.ref = "jetbrainsCompose" } 45 | jetbrains-compose-ui-testManifest = { group = "org.jetbrains.compose.ui", name = "ui-test-manifest", version.ref = "jetbrainsCompose" } 46 | jetbrains-compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview-desktop", version.ref = "jetbrainsCompose" } 47 | jetbrains-compose-ui-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "jetbrainsCompose" } 48 | jetbrains-compose-ui-util = { group = "org.jetbrains.compose.ui", name = "ui-util", version.ref = "jetbrainsCompose" } 49 | jetbrains-compose-material-icons-core = { group = "org.jetbrains.compose.material", name = "material-icons-core", version.ref = "materialIcons" } 50 | jetbrains-compose-material-icons-extended = { group = "org.jetbrains.compose.material", name = "material-icons-extended", version.ref = "materialIcons" } 51 | junit4 = { group = "junit", name = "junit", version.ref = "junit4" } 52 | dokka-gradlePlugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokka" } 53 | kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } 54 | kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } 55 | kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } 56 | kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } 57 | tunjid-mutator-core-common = { group = "com.tunjid.mutator", name = "core", version.ref = "tunjidMutator" } 58 | tunjid-mutator-core-jvm = { group = "com.tunjid.mutator", name = "core-jvm", version.ref = "tunjidMutator" } 59 | tunjid-mutator-coroutines-common = { group = "com.tunjid.mutator", name = "coroutines", version.ref = "tunjidMutator" } 60 | tunjid-mutator-coroutines-jvm = { group = "com.tunjid.mutator", name = "coroutines-jvm", version.ref = "tunjidMutator" } 61 | tunjid-treenav-common = { group = "com.tunjid.treenav", name = "treenav", version.ref = "tunjidTreeNav" } 62 | 63 | [plugins] 64 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } 65 | android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } 66 | androidx-benchmark = { id = "androidx.benchmark", version.ref = "androidxBenchmark" } 67 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 68 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 69 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 70 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 71 | jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } 72 | jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } 73 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | #Mon Jul 05 07:23:39 EDT 2021 18 | distributionBase=GRADLE_USER_HOME 19 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 20 | distributionPath=wrapper/dists 21 | zipStorePath=wrapper/dists 22 | zipStoreBase=GRADLE_USER_HOME 23 | -------------------------------------------------------------------------------- /library/compose/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /library/compose/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | kotlin("multiplatform") 19 | id("publishing-library-convention") 20 | id("android-library-convention") 21 | id("kotlin-jvm-convention") 22 | id("kotlin-library-convention") 23 | id("maven-publish") 24 | signing 25 | id("org.jetbrains.dokka") 26 | id("org.jetbrains.compose") 27 | alias(libs.plugins.compose.compiler) 28 | } 29 | 30 | android { 31 | buildFeatures { 32 | compose = true 33 | } 34 | } 35 | 36 | kotlin { 37 | js(IR) { 38 | nodejs() 39 | browser() 40 | } 41 | sourceSets { 42 | commonMain { 43 | dependencies { 44 | implementation(project(":library:tiler")) 45 | implementation(libs.kotlinx.coroutines.core) 46 | 47 | implementation(libs.jetbrains.compose.runtime) 48 | implementation(libs.jetbrains.compose.foundation) 49 | implementation(libs.jetbrains.compose.foundation.layout) 50 | } 51 | } 52 | commonTest { 53 | dependencies { 54 | implementation(kotlin("test")) 55 | implementation(libs.kotlinx.coroutines.test) 56 | implementation(libs.cashapp.turbine) 57 | } 58 | } 59 | all { 60 | languageSettings.apply { 61 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 62 | optIn("kotlinx.coroutines.FlowPreview") 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /library/compose/src/commonMain/kotlin/com/tunjid/tiler/compose/Effects.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.compose 18 | 19 | import androidx.compose.foundation.ExperimentalFoundationApi 20 | import androidx.compose.foundation.lazy.LazyListItemInfo 21 | import androidx.compose.foundation.lazy.LazyListState 22 | import androidx.compose.foundation.lazy.grid.LazyGridItemInfo 23 | import androidx.compose.foundation.lazy.grid.LazyGridState 24 | import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo 25 | import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState 26 | import androidx.compose.foundation.pager.PagerState 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.LaunchedEffect 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.runtime.rememberUpdatedState 31 | import androidx.compose.runtime.snapshotFlow 32 | import com.tunjid.tiler.TiledList 33 | import com.tunjid.tiler.queryAtOrNull 34 | import kotlinx.coroutines.flow.distinctUntilChanged 35 | 36 | @Composable 37 | fun LazyListState.PivotedTilingEffect( 38 | items: TiledList, 39 | indexSelector: IntRange.() -> Int = kotlin.ranges.IntRange::first, 40 | onQueryChanged: (Query?) -> Unit 41 | ) = PivotedTilingEffect( 42 | items = items, 43 | indexSelector = indexSelector, 44 | onQueryChanged = onQueryChanged, 45 | itemsList = { layoutInfo.visibleItemsInfo }, 46 | indexForItem = LazyListItemInfo::index 47 | ) 48 | 49 | @Composable 50 | fun LazyGridState.PivotedTilingEffect( 51 | items: TiledList, 52 | indexSelector: IntRange.() -> Int = kotlin.ranges.IntRange::first, 53 | onQueryChanged: (Query?) -> Unit 54 | ) = PivotedTilingEffect( 55 | items = items, 56 | indexSelector = indexSelector, 57 | onQueryChanged = onQueryChanged, 58 | itemsList = { layoutInfo.visibleItemsInfo }, 59 | indexForItem = LazyGridItemInfo::index 60 | ) 61 | 62 | @Composable 63 | fun LazyStaggeredGridState.PivotedTilingEffect( 64 | items: TiledList, 65 | indexSelector: IntRange.() -> Int = kotlin.ranges.IntRange::first, 66 | onQueryChanged: (Query?) -> Unit 67 | ) = PivotedTilingEffect( 68 | items = items, 69 | indexSelector = indexSelector, 70 | onQueryChanged = onQueryChanged, 71 | itemsList = { layoutInfo.visibleItemsInfo }, 72 | indexForItem = LazyStaggeredGridItemInfo::index 73 | ) 74 | 75 | @Composable 76 | @ExperimentalFoundationApi 77 | fun PagerState.PivotedTilingEffect( 78 | items: TiledList, 79 | onQueryChanged: (Query?) -> Unit 80 | ) { 81 | val updatedItems by rememberUpdatedState(items) 82 | LaunchedEffect(this) { 83 | snapshotFlow { 84 | updatedItems.queryAtOrNull(currentPage) 85 | } 86 | .distinctUntilChanged() 87 | .collect(onQueryChanged) 88 | } 89 | } 90 | 91 | @Composable 92 | private inline fun LazyState.PivotedTilingEffect( 93 | items: TiledList, 94 | noinline onQueryChanged: (Query?) -> Unit, 95 | crossinline indexSelector: IntRange.() -> Int = kotlin.ranges.IntRange::first, 96 | crossinline itemsList: LazyState.() -> List, 97 | crossinline indexForItem: (LazyStateItem) -> Int?, 98 | ) { 99 | val updatedItems by rememberUpdatedState(items) 100 | LaunchedEffect(this) { 101 | snapshotFlow { 102 | val visibleItemsInfo = itemsList(this@PivotedTilingEffect) 103 | val index = indexSelector(visibleItemsInfo.indices) 104 | val lazyStateItem = visibleItemsInfo.getOrNull(index) 105 | val itemIndex = lazyStateItem?.let(indexForItem) 106 | itemIndex?.let(updatedItems::queryAtOrNull) 107 | } 108 | .distinctUntilChanged() 109 | .collect(onQueryChanged) 110 | } 111 | } -------------------------------------------------------------------------------- /library/tiler/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /library/tiler/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | kotlin("multiplatform") 19 | id("publishing-library-convention") 20 | id("android-library-convention") 21 | id("kotlin-jvm-convention") 22 | id("kotlin-library-convention") 23 | id("maven-publish") 24 | signing 25 | id("org.jetbrains.dokka") 26 | } 27 | 28 | kotlin { 29 | js(IR) { 30 | nodejs() 31 | browser() 32 | } 33 | linuxX64() 34 | macosX64() 35 | macosArm64() 36 | mingwX64() 37 | tvosSimulatorArm64() 38 | watchosSimulatorArm64() 39 | sourceSets { 40 | commonMain { 41 | dependencies { 42 | implementation(libs.kotlinx.coroutines.core) 43 | } 44 | } 45 | commonTest { 46 | dependencies { 47 | implementation(kotlin("test")) 48 | implementation(libs.kotlinx.coroutines.test) 49 | implementation(libs.cashapp.turbine) 50 | } 51 | } 52 | all { 53 | languageSettings.apply { 54 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 55 | optIn("kotlinx.coroutines.FlowPreview") 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/TiledList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler 18 | 19 | import com.tunjid.tiler.utilities.EmptyTiledList 20 | import com.tunjid.tiler.utilities.SparseTiledList 21 | 22 | /** 23 | * A [List] where each item is backed by the [Query] that fetched it. 24 | * 25 | * A [Query] fetches one or more items, this association is called a [Tile]. i.e a [Tile] represents 26 | * a range of items associated with a particular [Query]. 27 | * 28 | * Note that [TiledList] instances should not be large. They should only contain enough 29 | * items to fill the device viewport a few items over to accommodate a user's scroll. 30 | * This is typically under 500 items. 31 | */ 32 | interface TiledList : List { 33 | /** 34 | * The number of [Tile] instances or query ranges there are in this [TiledList] 35 | */ 36 | val tileCount: Int 37 | 38 | /** 39 | * Returns the [Tile] at the specified tile index. 40 | */ 41 | fun tileAt(tileIndex: Int): Tile 42 | 43 | /** 44 | * Returns the query at the specified tile index. 45 | */ 46 | fun queryAtTile(tileIndex: Int): Query 47 | 48 | /** 49 | * Returns the query that fetched an [Item] at a specified index. 50 | */ 51 | fun queryAt(index: Int): Query 52 | } 53 | 54 | /** 55 | * A [TiledList] with mutation facilities. 56 | * 57 | * Note this exists to facilitate transformations on the outputs of a [ListTiler] 58 | */ 59 | interface MutableTiledList : TiledList { 60 | fun add(index: Int, query: Query, item: Item) 61 | 62 | fun add(query: Query, item: Item): Boolean 63 | 64 | fun addAll(query: Query, items: Collection): Boolean 65 | 66 | fun addAll(index: Int, query: Query, items: Collection): Boolean 67 | 68 | fun remove(index: Int): Item 69 | } 70 | 71 | /** 72 | * Returns an empty [TiledList] instance 73 | */ 74 | fun emptyTiledList(): TiledList = 75 | EmptyTiledList 76 | 77 | /** 78 | * Returns a read-only [TiledList] instance 79 | */ 80 | fun tiledListOf( 81 | vararg pairs: Pair 82 | ): TiledList = 83 | if (pairs.isEmpty()) emptyTiledList() else SparseTiledList(*pairs) 84 | 85 | /** 86 | * Returns a [MutableTiledList] instance 87 | */ 88 | fun mutableTiledListOf( 89 | vararg pairs: Pair 90 | ): MutableTiledList = 91 | SparseTiledList(*pairs) 92 | 93 | /** 94 | * Builds a new read-only List by populating a MutableList using the given builderAction and returning a read-only list with the same elements. 95 | */ 96 | fun buildTiledList( 97 | builderAction: MutableTiledList.() -> Unit 98 | ): TiledList = mutableTiledListOf() 99 | .also(builderAction::invoke) 100 | 101 | fun TiledList.queryAtOrNull(index: Int) = 102 | if (index in 0..lastIndex) queryAt(index) else null 103 | 104 | operator fun TiledList.plus( 105 | other: TiledList 106 | ): TiledList = buildTiledList { 107 | this@plus.forEachIndexed { index, item -> 108 | add(this@plus.queryAt(index), item) 109 | } 110 | other.forEachIndexed { index, item -> 111 | add(other.queryAt(index), item) 112 | } 113 | } 114 | 115 | fun TiledList<*, *>.strictEquals(other: TiledList<*, *>): Boolean { 116 | if (other === this) return true 117 | if (size != other.size) return false 118 | for (i in 0..lastIndex) { 119 | if (this[i] != other[i]) return false 120 | if (queryAt(i) != other.queryAt(i)) return false 121 | } 122 | return true 123 | } -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/TiledListExt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler 18 | 19 | /** 20 | * Returns all [Query] instances in [this] [TiledList] as a [List] 21 | */ 22 | inline fun TiledList.queries(): List = 23 | (0 until tileCount).map(::queryAtTile) 24 | 25 | /** 26 | * Returns all [Tile] instances in [this] [TiledList] as a [List]. 27 | * Note: Each [Tile] returned will be boxed. If autoboxing is not desired, iterate 28 | * between 0 and [TiledList.tileCount] instead. 29 | */ 30 | inline fun TiledList<*, *>.tiles(): List = 31 | (0 until tileCount).map(::tileAt) 32 | 33 | inline fun TiledList.transform( 34 | transformation: MutableTiledList.(index: Int) -> Unit 35 | ): TiledList { 36 | val output = mutableTiledListOf() 37 | for (i in 0..lastIndex) transformation(output, i) 38 | return output 39 | } 40 | 41 | /** 42 | * Equivalent to [List.filterIndexed] for [TiledList] 43 | */ 44 | inline fun TiledList.filterIndexed( 45 | predicate: (Int, Item) -> Boolean 46 | ): TiledList = 47 | transform { index -> 48 | val item = this@filterIndexed[index] 49 | if (predicate(index, item)) add( 50 | query = this@filterIndexed.queryAt(index), 51 | item = item 52 | ) 53 | } 54 | 55 | /** 56 | * Equivalent to [List.filter] for [TiledList] 57 | */ 58 | inline fun TiledList.filter( 59 | predicate: (Item) -> Boolean 60 | ): TiledList = 61 | filterIndexed { _, item -> 62 | predicate(item) 63 | } 64 | 65 | /** 66 | * Equivalent to [List.filterIsInstance] for [TiledList] 67 | */ 68 | @Suppress("UNCHECKED_CAST") 69 | inline fun TiledList.filterIsInstance( 70 | ): TiledList = 71 | filter { item -> 72 | item is Item 73 | } as TiledList 74 | 75 | /** 76 | * Equivalent to [List.mapIndexed] for [TiledList] 77 | */ 78 | inline fun TiledList.mapIndexed( 79 | mapper: (Int, T) -> R 80 | ): TiledList = 81 | transform { index -> 82 | val item = this@mapIndexed[index] 83 | add( 84 | query = this@mapIndexed.queryAt(index = index), 85 | item = mapper(index, item) 86 | ) 87 | } 88 | 89 | /** 90 | * Equivalent to [List.map] for [TiledList] 91 | */ 92 | inline fun TiledList.map( 93 | mapper: (T) -> R 94 | ): TiledList = 95 | mapIndexed { _, item -> 96 | mapper(item) 97 | } 98 | 99 | /** 100 | * Equivalent to [List.distinctBy] for [TiledList] 101 | */ 102 | inline fun TiledList.distinctBy( 103 | selector: (T) -> K 104 | ): TiledList { 105 | val set = mutableSetOf() 106 | return transform { index -> 107 | val item = this@distinctBy[index] 108 | val key = selector(item) 109 | if (!set.contains(key)) { 110 | set.add(key) 111 | add( 112 | query = this@distinctBy.queryAt(index = index), 113 | item = item 114 | ) 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Equivalent to [List.distinct] for [TiledList] 121 | */ 122 | inline fun TiledList.distinct(): TiledList = 123 | distinctBy { it } 124 | 125 | /** 126 | * Equivalent to [List.groupBy] for [TiledList] 127 | */ 128 | inline fun TiledList.groupBy( 129 | keySelector: (T) -> K 130 | ): Map> { 131 | val groupedItems = linkedMapOf>() 132 | forEachIndexed { index, item -> 133 | val mutableTiledList = groupedItems.getOrPut( 134 | key = keySelector(item), 135 | defaultValue = ::mutableTiledListOf 136 | ) 137 | mutableTiledList.add( 138 | query = queryAt(index), 139 | item = item 140 | ) 141 | } 142 | return groupedItems 143 | } 144 | -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/utilities/ChunkedTiledList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.utilities 18 | 19 | import com.tunjid.tiler.Tile 20 | import com.tunjid.tiler.TiledList 21 | import com.tunjid.tiler.strictEquals 22 | 23 | /** 24 | * A sorted read only [TiledList] implementation that offers O(1) retrieval of items if the size 25 | * is known ahead of time or O(log(n)) time otherwise. 26 | */ 27 | internal inline fun chunkedTiledList( 28 | chunkSizeHint: Int?, 29 | indices: IntArrayList, 30 | crossinline queryLookup: (Int) -> Query, 31 | crossinline itemsLookup: (Query) -> List, 32 | ): TiledList { 33 | 34 | val numberOfChunks = indices.size 35 | val chunkSizes = IntArray(numberOfChunks) 36 | val queries = arrayOfNulls(numberOfChunks) 37 | val chunkedItems = arrayOfNulls>(numberOfChunks) 38 | 39 | var size = 0 40 | 41 | for (i in 0 until numberOfChunks) { 42 | val query = queryLookup(indices[i]) 43 | val items = itemsLookup(query) 44 | size += items.size 45 | chunkSizes[i] = size 46 | queries[i] = query 47 | chunkedItems[i] = items 48 | } 49 | 50 | return ChunkedTiledList( 51 | chunkSizeHint = chunkSizeHint, 52 | size = size, 53 | chunkSizes = chunkSizes, 54 | queries = queries, 55 | chunkedItems = chunkedItems, 56 | ) 57 | } 58 | 59 | internal class ChunkedTiledList( 60 | override val size: Int, 61 | private val chunkSizeHint: Int?, 62 | private val chunkSizes: IntArray, 63 | private val queries: Array, 64 | private val chunkedItems: Array?>, 65 | ) : AbstractList(), TiledList { 66 | 67 | override val tileCount: Int = queries.size 68 | 69 | override fun tileAt(tileIndex: Int): Tile = Tile( 70 | start = if (tileIndex == 0) 0 else chunkSizes[tileIndex - 1], 71 | end = chunkSizes[tileIndex] 72 | ) 73 | 74 | @Suppress("UNCHECKED_CAST") 75 | override fun queryAtTile(tileIndex: Int): Query = queries[tileIndex] as Query 76 | 77 | @Suppress("UNCHECKED_CAST") 78 | override fun queryAt(index: Int): Query = withItemAtIndex( 79 | index 80 | ) { chunkIndex, _ -> queries[chunkIndex] as Query } 81 | 82 | @Suppress("UNCHECKED_CAST") 83 | override fun get(index: Int): Item = withItemAtIndex( 84 | index 85 | ) { chunkIndex, indexInChunk -> chunkedItems[chunkIndex]?.get(indexInChunk) as Item } 86 | 87 | override fun hashCode(): Int = 88 | (31 * "ChunkedTiledList".hashCode()) + super.hashCode() 89 | 90 | override fun equals(other: Any?): Boolean = 91 | if (other is TiledList<*, *>) strictEquals(other) 92 | else super.equals(other) 93 | 94 | private inline fun withItemAtIndex( 95 | index: Int, 96 | crossinline retriever: (chunkIndex: Int, indexInChunk: Int) -> T 97 | ): T { 98 | if (isEmpty()) throw IndexOutOfBoundsException( 99 | "Trying to read $index in empty TiledList" 100 | ) 101 | // Get item in constant time 102 | if (chunkSizeHint != null) return retriever( 103 | index / chunkSizeHint, 104 | index % chunkSizeHint 105 | ) 106 | val chunkIndex = chunkSizes.findIndexInChunkSizes(index) 107 | 108 | // Get Item in O(log(N)) time 109 | return retriever( 110 | chunkIndex, 111 | when (chunkIndex) { 112 | 0 -> index 113 | else -> index - chunkSizes[chunkIndex - 1] 114 | } 115 | ) 116 | } 117 | } 118 | 119 | private fun IntArray.findIndexInChunkSizes( 120 | index: Int, 121 | ): Int { 122 | var low = 0 123 | var high = size - 1 124 | while (low <= high) { 125 | val mid = (low + high).ushr(1) 126 | val comparison = get(mid).compareTo(index) 127 | 128 | if (comparison < 0) low = mid + 1 129 | else if (comparison > 0) high = mid - 1 130 | else return mid + 1 // Found, the item is in the next chunk 131 | } 132 | 133 | return low 134 | } -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/utilities/EmptyTiledList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.utilities 18 | 19 | import com.tunjid.tiler.Tile 20 | import com.tunjid.tiler.TiledList 21 | 22 | internal object EmptyTiledList : TiledList, List by emptyList() { 23 | override val tileCount: Int 24 | get() = 0 25 | 26 | override fun tileAt(tileIndex: Int): Tile = 27 | throw IndexOutOfBoundsException("Empty tiled list doesn't contain tile at index $tileIndex.") 28 | 29 | override fun queryAtTile(tileIndex: Int): Nothing = 30 | throw IndexOutOfBoundsException("Empty tiled list doesn't contain query at tile index $tileIndex.") 31 | 32 | override fun queryAt(index: Int): Nothing = 33 | throw IndexOutOfBoundsException("Empty tiled list doesn't contain element at index $index.") 34 | 35 | override fun equals(other: Any?): Boolean = other is TiledList<*, *> && other.isEmpty() 36 | override fun hashCode(): Int = 1 37 | override fun toString(): String = "[]" 38 | } 39 | 40 | -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/utilities/IntArrayList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.utilities 18 | 19 | internal class IntArrayList( 20 | initialSize: Int = 10 21 | ) { 22 | 23 | private var data = IntArray(initialSize) 24 | var size = 0 25 | private set 26 | 27 | val lastIndex get() = size - 1 28 | 29 | fun add(element: Int) { 30 | if (size == data.size) { 31 | val newData = IntArray(data.size * 2) 32 | data.copyInto(destination = newData) 33 | data = newData 34 | } 35 | data[size++] = element 36 | } 37 | 38 | fun add(index: Int, element: Int) { 39 | when (size) { 40 | // Grow the backing array 41 | data.size -> { 42 | val newData = IntArray(data.size * 2) 43 | data.copyInto( 44 | destination = newData, 45 | startIndex = 0, 46 | endIndex = index, 47 | ) 48 | data.copyInto( 49 | destination = newData, 50 | destinationOffset = index + 1, 51 | startIndex = index, 52 | ) 53 | newData[index] = element 54 | data = newData 55 | } 56 | // Copy items over and plug in the gap 57 | else -> { 58 | data.copyInto( 59 | destination = data, 60 | destinationOffset = index + 1, 61 | startIndex = index, 62 | endIndex = size, 63 | ) 64 | data[index] = element 65 | } 66 | } 67 | ++size 68 | } 69 | 70 | fun removeAt(index: Int) { 71 | if (index < 0 || index > lastIndex) throw IndexOutOfBoundsException( 72 | "Attempted to remove at $index in IntArrayList of size $size" 73 | ) 74 | data.copyInto( 75 | destination = data, 76 | destinationOffset = index, 77 | startIndex = index + 1, 78 | endIndex = size 79 | ) 80 | --size 81 | } 82 | 83 | operator fun get(index: Int): Int { 84 | if (index > lastIndex) throw IndexOutOfBoundsException( 85 | "Attempted to read $index in IntArrayList of size $size" 86 | ) 87 | return data[index] 88 | } 89 | 90 | operator fun set(index: Int, element: Int) { 91 | data[index] = element 92 | } 93 | 94 | fun isEmpty() = size == 0 95 | 96 | fun clear() { 97 | size = 0 98 | } 99 | 100 | override fun equals(other: Any?): Boolean { 101 | if (other === this) return true 102 | if (other !is IntArrayList) return false 103 | 104 | return orderedEquals(this, other) 105 | } 106 | 107 | /** 108 | * Returns the hash code value for this list. 109 | */ 110 | override fun hashCode(): Int = orderedHashCode(this) 111 | 112 | private fun orderedEquals(c: IntArrayList, other: IntArrayList): Boolean { 113 | if (c.size != other.size) return false 114 | 115 | for (i in 0..lastIndex) { 116 | val elem = this[i] 117 | val elemOther = other[i] 118 | if (elem != elemOther) { 119 | return false 120 | } 121 | } 122 | return true 123 | } 124 | 125 | private fun orderedHashCode(c: IntArrayList): Int { 126 | var hashCode = 1 127 | for (i in 0..lastIndex) { 128 | val elem = c[i] 129 | hashCode = 31 * hashCode + elem.hashCode() 130 | } 131 | return hashCode 132 | } 133 | } 134 | 135 | internal fun IntArrayList.toList(): List = buildList build@{ 136 | for (i in 0..this@toList.lastIndex) { 137 | this@build.add(this@toList[i]) 138 | } 139 | } -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/utilities/NeighbouredQueryFetcher.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.utilities 18 | 19 | import com.tunjid.tiler.QueryFetcher 20 | import kotlinx.coroutines.flow.Flow 21 | import kotlinx.coroutines.flow.MutableStateFlow 22 | import kotlinx.coroutines.flow.distinctUntilChanged 23 | import kotlinx.coroutines.flow.flatMapLatest 24 | import kotlinx.coroutines.flow.map 25 | import kotlinx.coroutines.flow.mapNotNull 26 | import kotlinx.coroutines.flow.update 27 | import kotlin.math.max 28 | 29 | /** 30 | * Defines the result of a fetch that provides [Token] instances to be fed back to other fetches 31 | * that neighbor this one. 32 | */ 33 | data class NeighboredFetchResult( 34 | /** 35 | * A [Map] of [Query] to [Token] for queries adjacent to the query that fetched this 36 | * [NeighboredFetchResult]. They will be used to provide tokens for the adjacent queries. 37 | */ 38 | val neighborQueriesToTokens: Map, 39 | /** 40 | * The list of items for a particular query 41 | */ 42 | val items: List, 43 | ) 44 | 45 | /** 46 | * Returns a [QueryFetcher] for fetching queries that depend on the results of queries neighboring 47 | * it. Typically this is a paginated remote API that returns tokens in each 48 | * [NeighboredFetchResult], however it may also be used to enforce that queries are fetched by 49 | * proximity to a certain query. 50 | * 51 | * 52 | * @param maxTokens the maximum number of tokens to keep in memory. They are evicted on a LIFO basis. 53 | * @param seedQueryTokenMap the initial tokens present. The first [Query] should be contained in 54 | * this at a minimum. 55 | * @param fetcher fetches a [NeighboredFetchResult] for a given [Query] and [Token] 56 | */ 57 | fun neighboredQueryFetcher( 58 | maxTokens: Int, 59 | seedQueryTokenMap: Map, 60 | fetcher: suspend (Query, Token) -> Flow>, 61 | ): QueryFetcher = NeighbouredQueryFetcher( 62 | maxTokens = maxTokens, 63 | seedQueryTokenMap = seedQueryTokenMap, 64 | fetcher = fetcher, 65 | ) 66 | 67 | internal class NeighbouredQueryFetcher( 68 | private val maxTokens: Int, 69 | seedQueryTokenMap: Map, 70 | val fetcher: suspend (Query, Token) -> Flow>, 71 | ) : QueryFetcher { 72 | 73 | internal val queriesToTokens = MutableStateFlow(LinkedHashMap(seedQueryTokenMap)) 74 | 75 | init { 76 | if (seedQueryTokenMap.isEmpty()) throw IllegalArgumentException( 77 | "seed queries and tokens are empty, no items will ever be fetched." 78 | ) 79 | } 80 | 81 | override suspend fun fetch(query: Query): Flow> = 82 | queriesToTokens.mapNotNull { it[query] } 83 | .distinctUntilChanged() 84 | .flatMapLatest { token -> 85 | fetcher(query, token).map { result -> 86 | result.items.also { 87 | if (result.neighborQueriesToTokens.isEmpty()) return@also 88 | queriesToTokens.update { queue -> 89 | val updatedLinkedMap = LinkedHashMap(queue) 90 | updatedLinkedMap.putAll(result.neighborQueriesToTokens) 91 | val diff = max(a = 0, b = updatedLinkedMap.size - maxTokens) 92 | // Get the oldest items 93 | val toEvict = updatedLinkedMap.keys.take(diff) 94 | // Remove the oldest items 95 | toEvict.forEach(updatedLinkedMap::remove) 96 | updatedLinkedMap 97 | } 98 | } 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /library/tiler/src/commonMain/kotlin/com/tunjid/tiler/utilities/SparseTiledList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler.utilities 18 | 19 | import com.tunjid.tiler.MutableTiledList 20 | import com.tunjid.tiler.Tile 21 | import com.tunjid.tiler.TiledList 22 | import com.tunjid.tiler.strictEquals 23 | 24 | 25 | /** 26 | * A [TiledList] implementation that associates each [Item] with its [Query] using a 27 | * [SparseQueryArray] 28 | */ 29 | internal class SparseTiledList( 30 | vararg pairs: Pair 31 | ) : AbstractList(), MutableTiledList { 32 | 33 | private val tileQueryMap = SparseQueryArray(pairs.size) 34 | private val items: MutableList = mutableListOf() 35 | 36 | init { 37 | for (pair in pairs) add( 38 | query = pair.first, 39 | item = pair.second 40 | ) 41 | } 42 | 43 | override val size: Int get() = items.size 44 | 45 | override val tileCount: Int 46 | get() = tileQueryMap.size 47 | 48 | override fun tileAt(tileIndex: Int): Tile = tileQueryMap.tileAt(tileIndex) 49 | 50 | override fun queryAt(index: Int): Query { 51 | if (isEmpty() || index !in 0..lastIndex) throw IndexOutOfBoundsException() 52 | return tileQueryMap.queryAt(index) ?: throw IndexOutOfBoundsException() 53 | } 54 | 55 | override fun queryAtTile(tileIndex: Int): Query = tileQueryMap.queryAtTile(tileIndex) 56 | 57 | override fun get(index: Int): Item = items[index] 58 | 59 | override fun add(index: Int, query: Query, item: Item) { 60 | tileQueryMap.insertQuery( 61 | index = index, 62 | query = query, 63 | count = 1 64 | ) 65 | this.items.add(index, item) 66 | } 67 | 68 | override fun add(query: Query, item: Item): Boolean { 69 | tileQueryMap.appendQuery( 70 | query = query, 71 | count = 1 72 | ) 73 | return this.items.add(item) 74 | } 75 | 76 | override fun addAll(query: Query, items: Collection): Boolean { 77 | if (items.isEmpty()) return false 78 | tileQueryMap.appendQuery( 79 | query = query, 80 | count = items.size 81 | ) 82 | this.items.addAll(items) 83 | return true 84 | } 85 | 86 | override fun addAll(index: Int, query: Query, items: Collection): Boolean { 87 | if (items.isEmpty()) return false 88 | tileQueryMap.insertQuery( 89 | index = index, 90 | query = query, 91 | count = items.size 92 | ) 93 | this.items.addAll(index = index, elements = items) 94 | return true 95 | } 96 | 97 | override fun remove(index: Int): Item { 98 | tileQueryMap.deleteAt(index) 99 | return items.removeAt(index) 100 | } 101 | 102 | override fun hashCode(): Int = 103 | (31 * "SparseTiledList".hashCode()) + super.hashCode() 104 | 105 | override fun equals(other: Any?): Boolean = 106 | if (other is TiledList<*, *>) strictEquals(other) 107 | else super.equals(other) 108 | 109 | } 110 | -------------------------------------------------------------------------------- /library/tiler/src/commonTest/kotlin/com/tunjid/tiler/TiledListKtTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler 18 | 19 | import com.tunjid.tiler.utilities.toList 20 | import com.tunjid.utilities.queries 21 | import kotlin.test.Test 22 | import kotlin.test.assertEquals 23 | import kotlin.test.assertNotEquals 24 | 25 | 26 | class TiledListKtTest { 27 | 28 | @Test 29 | fun tiled_list_builder_works() { 30 | val tiledList = buildTiledList { 31 | addAll(1, 1.testRange().toList()) 32 | addAll(3, 3.testRange().toList()) 33 | } 34 | assertEquals( 35 | expected = 1.tiledTestRange() + 3.tiledTestRange(), 36 | actual = tiledList 37 | ) 38 | assertEquals( 39 | expected = listOf(1,3), 40 | actual = tiledList.queries() 41 | ) 42 | } 43 | 44 | @Test 45 | fun empty_tiled_list_works() { 46 | assertEquals( 47 | expected = tiledListOf(), 48 | actual = emptyTiledList() 49 | ) 50 | } 51 | 52 | @Test 53 | fun equals_fails_with_different_items() { 54 | assertNotEquals( 55 | illegal = tiledListOf( 56 | 0 to 0, 57 | 0 to 1, 58 | 0 to 2, 59 | ), 60 | actual = tiledListOf( 61 | 0 to 0, 62 | 0 to 3, 63 | 0 to 2, 64 | ) 65 | ) 66 | } 67 | 68 | @Test 69 | fun equals_works_with_simple_list() { 70 | val tiledList = tiledListOf( 71 | 0 to 0, 72 | 0 to 1, 73 | 0 to 2, 74 | ) 75 | assertEquals( 76 | expected = listOf( 77 | 0, 78 | 1, 79 | 2, 80 | ), 81 | actual = tiledList 82 | ) 83 | assertEquals( 84 | expected = listOf(0), 85 | actual = tiledList.queries() 86 | ) 87 | } 88 | } -------------------------------------------------------------------------------- /library/tiler/src/commonTest/kotlin/com/tunjid/tiler/Utilities.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tiler 18 | 19 | import kotlinx.coroutines.flow.Flow 20 | import kotlinx.coroutines.withTimeoutOrNull 21 | 22 | private const val ITEMS_PER_PAGE = 10 23 | internal fun Int.testRange( 24 | itemsPerPage: Int = ITEMS_PER_PAGE, 25 | ): IntRange { 26 | val offset = this * itemsPerPage 27 | val next = offset + itemsPerPage 28 | 29 | return offset until next 30 | } 31 | 32 | internal fun Int.tiledTestRange( 33 | itemsPerPage: Int = ITEMS_PER_PAGE, 34 | transform: List.() -> List = { this } 35 | ) = buildTiledList { 36 | addAll(query = this@tiledTestRange, items = transform(testRange(itemsPerPage).toList())) 37 | } 38 | 39 | suspend fun Flow.toListWithTimeout(timeoutMillis: Long): List { 40 | val result = mutableListOf() 41 | return withTimeoutOrNull(timeoutMillis) { 42 | collect(result::add) 43 | result 44 | } ?: result 45 | } 46 | 47 | private fun TiledList<*, *>.asPairedList(): List> = 48 | mapIndexed { index, item -> queryAt(index) to item } 49 | -------------------------------------------------------------------------------- /library/tiler/src/commonTest/kotlin/com/tunjid/utilities/ChunkedTiledListTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.utilities 18 | 19 | import com.tunjid.tiler.TiledList 20 | import com.tunjid.tiler.size 21 | import com.tunjid.tiler.tiledListOf 22 | import com.tunjid.tiler.utilities.IntArrayList 23 | import com.tunjid.tiler.utilities.chunkedTiledList 24 | import com.tunjid.tiler.utilities.toList 25 | import kotlin.test.Test 26 | import kotlin.test.assertEquals 27 | 28 | class ChunkedTiledListTest { 29 | 30 | @Test 31 | fun chunked_tiled_indexing_works() { 32 | (0..10).forEach { chunkSize -> 33 | val (constantTimeChunkedTiledList, binarySearchChunkedTiledList, indices) = 34 | optimizedAndIterativeChunkedLists(chunkSize) 35 | 36 | val expectedTiledList = consecutiveIntegerTiledList( 37 | chunkSize = chunkSize 38 | ) 39 | assertEquals( 40 | expected = expectedTiledList, 41 | actual = constantTimeChunkedTiledList 42 | ) 43 | assertEquals( 44 | expected = constantTimeChunkedTiledList, 45 | actual = binarySearchChunkedTiledList 46 | ) 47 | assertEquals( 48 | expected = indices.toList(), 49 | actual = constantTimeChunkedTiledList.queries() 50 | ) 51 | assertEquals( 52 | expected = indices.toList(), 53 | actual = binarySearchChunkedTiledList.queries() 54 | ) 55 | (0 until constantTimeChunkedTiledList.tileCount).forEach { tileIndex -> 56 | assertEquals( 57 | expected = chunkSize, 58 | actual = constantTimeChunkedTiledList.tileAt(tileIndex).size 59 | ) 60 | assertEquals( 61 | expected = chunkSize, 62 | actual = binarySearchChunkedTiledList.tileAt(tileIndex).size 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | 69 | private fun optimizedAndIterativeChunkedLists( 70 | chunkSize: Int 71 | ): Triple, TiledList, IntArrayList> { 72 | val indices = IntArrayList(chunkSize).apply { 73 | (0 until chunkSize).forEach(::add) 74 | } 75 | 76 | val constantTimeChunkedTiledList = chunkedTiledList( 77 | chunkSizeHint = chunkSize, 78 | indices = indices, 79 | queryLookup = indices::get, 80 | itemsLookup = { index -> 81 | val offset = index * chunkSize 82 | (0 until chunkSize).map(offset::plus) 83 | } 84 | ) 85 | val binarySearchChunkedTiledList = chunkedTiledList( 86 | chunkSizeHint = null, 87 | indices = indices, 88 | queryLookup = indices::get, 89 | itemsLookup = { index -> 90 | val offset = index * chunkSize 91 | (0 until chunkSize).map(offset::plus) 92 | } 93 | ) 94 | return Triple( 95 | first = constantTimeChunkedTiledList, 96 | second = binarySearchChunkedTiledList, 97 | third = indices 98 | ) 99 | } 100 | 101 | private fun consecutiveIntegerTiledList( 102 | chunkSize: Int, 103 | upUntilInt: Int = chunkSize * chunkSize 104 | ) = tiledListOf( 105 | *(0 until upUntilInt) 106 | .map { item -> 107 | val query = item / chunkSize 108 | query to item 109 | } 110 | .toTypedArray() 111 | ) 112 | -------------------------------------------------------------------------------- /library/tiler/src/commonTest/kotlin/com/tunjid/utilities/IntArrayListTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.utilities 18 | 19 | import com.tunjid.tiler.utilities.IntArrayList 20 | import com.tunjid.tiler.utilities.toList 21 | import kotlin.test.Test 22 | import kotlin.test.assertEquals 23 | import kotlin.test.assertFailsWith 24 | 25 | class IntArrayListTest { 26 | 27 | @Test 28 | fun can_add() { 29 | val intList = IntArrayList() 30 | intList.add(1) 31 | assertEquals( 32 | expected = 1, 33 | actual = intList.size 34 | ) 35 | 36 | intList.add(2) 37 | assertEquals( 38 | expected = listOf(1, 2), 39 | actual = intList.toList() 40 | ) 41 | 42 | assertEquals( 43 | expected = 2, 44 | actual = intList.size 45 | ) 46 | } 47 | 48 | @Test 49 | fun can_add_at_index() { 50 | val intList = IntArrayList() 51 | 52 | intList.add(1) 53 | assertEquals( 54 | expected = 1, 55 | actual = intList.size 56 | ) 57 | 58 | intList.add(2) 59 | assertEquals( 60 | expected = 2, 61 | actual = intList.size 62 | ) 63 | 64 | intList.add( 65 | index = 1, 66 | element = 3 67 | ) 68 | assertEquals( 69 | expected = 3, 70 | actual = intList.size 71 | ) 72 | assertEquals( 73 | expected = listOf(1, 3, 2), 74 | actual = intList.toList() 75 | ) 76 | 77 | intList.add( 78 | index = 0, 79 | element = 9 80 | ) 81 | assertEquals( 82 | expected = 4, 83 | actual = intList.size 84 | ) 85 | assertEquals( 86 | expected = listOf(9, 1, 3, 2), 87 | actual = intList.toList() 88 | ) 89 | } 90 | 91 | @Test 92 | fun can_set_at_index() { 93 | val intList = IntArrayList() 94 | intList.add(1) 95 | intList.add(2) 96 | intList.add(3) 97 | intList.add(4) 98 | 99 | assertEquals( 100 | expected = listOf(1, 2, 3, 4), 101 | actual = intList.toList() 102 | ) 103 | 104 | for(i in 0..intList.lastIndex) { 105 | intList[i] = intList[i] + 5 106 | } 107 | 108 | assertEquals( 109 | expected = listOf(6, 7, 8, 9), 110 | actual = intList.toList() 111 | ) 112 | } 113 | 114 | @Test 115 | fun can_resize() { 116 | val intList = IntArrayList(1) 117 | 118 | intList.add(1) 119 | assertEquals( 120 | expected = 1, 121 | actual = intList.size 122 | ) 123 | 124 | intList.add(2) 125 | assertEquals( 126 | expected = 2, 127 | actual = intList.size 128 | ) 129 | 130 | assertEquals( 131 | expected = listOf(1, 2), 132 | actual = intList.toList() 133 | ) 134 | } 135 | 136 | @Test 137 | fun can_removeAt() { 138 | val intList = IntArrayList(1) 139 | 140 | intList.add(1) 141 | intList.add(2) 142 | intList.add(3) 143 | intList.add(4) 144 | intList.add(5) 145 | 146 | assertEquals( 147 | expected = listOf(1, 2, 3, 4, 5), 148 | actual = intList.toList() 149 | ) 150 | 151 | intList.removeAt(0) 152 | assertEquals( 153 | expected = listOf(2, 3, 4, 5), 154 | actual = intList.toList() 155 | ) 156 | 157 | intList.removeAt(2) 158 | assertEquals( 159 | expected = listOf(2, 3, 5), 160 | actual = intList.toList() 161 | ) 162 | 163 | intList.removeAt(2) 164 | assertEquals( 165 | expected = listOf(2, 3), 166 | actual = intList.toList() 167 | ) 168 | } 169 | 170 | @Test 171 | fun cannot_removeAt_out_of_bounds() { 172 | val intList = IntArrayList(1) 173 | 174 | intList.add(1) 175 | intList.add(2) 176 | intList.add(3) 177 | 178 | assertFailsWith { 179 | intList.removeAt(7) 180 | } 181 | } 182 | } -------------------------------------------------------------------------------- /library/tiler/src/commonTest/kotlin/com/tunjid/utilities/NeighboredQueryFetcherTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.utilities 18 | 19 | import com.tunjid.tiler.invoke 20 | import com.tunjid.tiler.tiledTestRange 21 | import com.tunjid.tiler.toListWithTimeout 22 | import com.tunjid.tiler.utilities.NeighboredFetchResult 23 | import com.tunjid.tiler.utilities.NeighbouredQueryFetcher 24 | import com.tunjid.tiler.utilities.neighboredQueryFetcher 25 | import kotlinx.coroutines.flow.first 26 | import kotlinx.coroutines.flow.flowOf 27 | import kotlinx.coroutines.test.runTest 28 | import kotlin.test.Test 29 | import kotlin.test.assertEquals 30 | import kotlin.test.assertTrue 31 | 32 | class NeighboredQueryFetcherTest { 33 | 34 | @Test 35 | fun can_fetch_seed_item() = runTest { 36 | val fetcher = testTokenizedQueryFetcher() 37 | assertEquals( 38 | expected = 0.tiledTestRange(), 39 | actual = fetcher.invoke(query = 0).first() 40 | ) 41 | } 42 | 43 | @Test 44 | fun cannot_fetch_query_till_adjacent_query_is_available() = runTest { 45 | val fetcher = testTokenizedQueryFetcher() 46 | 47 | // 0 is unavailable, so 1 should produce nothing 48 | assertTrue( 49 | fetcher.invoke(query = 1) 50 | .toListWithTimeout(10) 51 | .isEmpty() 52 | ) 53 | 54 | // Fetch for 0 55 | assertEquals( 56 | expected = 0.tiledTestRange(), 57 | actual = fetcher.invoke(query = 0).first() 58 | ) 59 | 60 | // Fetch for 1 61 | assertEquals( 62 | expected = 1.tiledTestRange(), 63 | actual = fetcher.invoke(query = 1).first() 64 | ) 65 | } 66 | 67 | @Test 68 | fun tokens_are_evicted_LIFO() = runTest { 69 | val fetcher = testTokenizedQueryFetcher(maxTokens = 5) 70 | (0..6).forEach { page -> 71 | assertEquals( 72 | expected = page.tiledTestRange(), 73 | actual = fetcher.invoke(query = page).first() 74 | ) 75 | } 76 | val tokenizedQueryFetcher = fetcher as NeighbouredQueryFetcher<*, *, *> 77 | 78 | assertEquals( 79 | expected = 5, 80 | actual = tokenizedQueryFetcher.queriesToTokens.value.keys.size 81 | ) 82 | assertEquals( 83 | expected = (3..7).toList(), 84 | actual = tokenizedQueryFetcher.queriesToTokens.value.keys.toList() 85 | ) 86 | } 87 | 88 | @Test 89 | fun exception_is_thrown_if_no_seed() = runTest { 90 | val fetcher = testTokenizedQueryFetcher(maxTokens = 5) 91 | (0..6).forEach { page -> 92 | assertEquals( 93 | expected = page.tiledTestRange(), 94 | actual = fetcher.invoke(query = page).first() 95 | ) 96 | } 97 | val tokenizedQueryFetcher = fetcher as NeighbouredQueryFetcher<*, *, *> 98 | 99 | assertEquals( 100 | expected = 5, 101 | actual = tokenizedQueryFetcher.queriesToTokens.value.keys.size 102 | ) 103 | assertEquals( 104 | expected = (3..7).toList(), 105 | actual = tokenizedQueryFetcher.queriesToTokens.value.keys.toList() 106 | ) 107 | } 108 | } 109 | 110 | private fun testTokenizedQueryFetcher( 111 | maxTokens: Int = 5, 112 | ) = neighboredQueryFetcher( 113 | maxTokens = maxTokens, 114 | seedQueryTokenMap = mapOf(0 to "a token"), 115 | fetcher = { page, token -> 116 | val mockApiResult = MockApiResult( 117 | nextPageToken = token.hashCode().toString(), 118 | previousPageToken = if (page >= 0) token.reversed().hashCode().toString() else null, 119 | items = page.tiledTestRange() 120 | ) 121 | flowOf( 122 | NeighboredFetchResult( 123 | neighborQueriesToTokens = listOfNotNull( 124 | mockApiResult.nextPageToken?.let { page + 1 to it }, 125 | mockApiResult.previousPageToken?.let { page - 1 to it }, 126 | ) 127 | .toMap(), 128 | items = mockApiResult.items 129 | ) 130 | ) 131 | } 132 | ) 133 | 134 | private data class MockApiResult( 135 | val nextPageToken: String?, 136 | val previousPageToken: String?, 137 | val items: List, 138 | ) -------------------------------------------------------------------------------- /libraryVersion.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | groupId=com.tunjid.tiler 18 | tiler_version=1.2.0 19 | compose_version=1.2.0 20 | -------------------------------------------------------------------------------- /misc/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/misc/demo.gif -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | 3 | # General setup 4 | site_name: Tiler 5 | site_url: https://tunjid.github.io/Tiler/ 6 | site_author: Adetunji Dahunsi 7 | site_description: A reactive state based pagination library 8 | 9 | theme: 10 | name: material 11 | logo: assets/logo.png 12 | icon: 13 | repo: fontawesome/brands/github 14 | features: 15 | - content.code.copy 16 | language: en 17 | include_search_page: false 18 | search_index_only: true 19 | palette: 20 | # Palette toggle for light mode 21 | - media: "(prefers-color-scheme: light)" 22 | scheme: default 23 | primary: teal 24 | accent: teal 25 | toggle: 26 | icon: material/weather-night 27 | name: Switch to dark mode 28 | 29 | # Palette toggle for dark mode 30 | - media: "(prefers-color-scheme: dark)" 31 | scheme: slate 32 | primary: teal 33 | accent: teal 34 | toggle: 35 | icon: material/weather-sunny 36 | name: Switch to light mode 37 | repo_url: https://github.com/tunjid/Tiler 38 | nav: 39 | - Home: index.md 40 | - Tiled lists: implementation/tiledlist.md 41 | - Tiling use cases and examples: 42 | - Overview: usecases/overview.md 43 | - Basic Example: usecases/basic-example.md 44 | - Placeholders: usecases/placeholders.md 45 | - Search: usecases/search.md 46 | - Adaptive Paging: usecases/adaptive-paging.md 47 | - Adaptive Paged Search with Placeholders: usecases/complex-tiling.md 48 | - Transformations: usecases/transformations.md 49 | - Jetpack Compose: usecases/compose.md 50 | - How tiling works: 51 | - Primitives: implementation/primitives.md 52 | - Pivoting: implementation/pivoted-tiling.md 53 | - Pagination types and Tiling: implementation/pagination-types.md 54 | - Performance: implementation/performance.md 55 | markdown_extensions: 56 | - admonition 57 | - pymdownx.highlight: 58 | anchor_linenums: true 59 | line_spans: __span 60 | pygments_lang_class: true 61 | - pymdownx.details 62 | - pymdownx.inlinehilite 63 | - pymdownx.snippets 64 | - pymdownx.superfences -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Tiler 2 | 3 | [![JVM Tests](https://github.com/tunjid/Tiler/actions/workflows/tests.yml/badge.svg)](https://github.com/tunjid/Tiler/actions/workflows/tests.yml) 4 | ![Tiler](https://img.shields.io/maven-central/v/com.tunjid.tiler/tiler?label=tiler) 5 | 6 | ![badge][badge-ios] 7 | ![badge][badge-js] 8 | ![badge][badge-jvm] 9 | ![badge][badge-linux] 10 | ![badge][badge-windows] 11 | ![badge][badge-mac] 12 | ![badge][badge-tvos] 13 | ![badge][badge-watchos] 14 | 15 | Please note, this is not an official Google repository. It is a Kotlin multiplatform experiment that makes no guarantees 16 | about API stability or long term support. None of the works presented here are production tested, and should not be 17 | taken as anything more than its face value. 18 | 19 | ## Introduction 20 | 21 | Tiling is a state based paging implementation that presents a sublist of paged dataset in a simple `List`. 22 | It offers constant time access to items at indices, and the ability to introspect the items paged through. 23 | 24 | The following are examples of paged UI/UX paradigms that were built using tiling: 25 | 26 | | [Basic pagination](https://tunjid.github.io/Tiler/usecases/basic-example/) | [Pagination with sticky headers](https://tunjid.github.io/Tiler/usecases/compose/#sticky-headers) | [Pagination with search](https://tunjid.github.io/Tiler/usecases/search/) | [Pagination with placeholders](https://tunjid.github.io/Tiler/usecases/placeholders/) | 27 | |----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:| 28 | | ![Basic pagination](./docs/images/basic.gif) | ![Pagination with sticky headers](./docs/images/sticky.gif) | ![Pagination with search](./docs/images/search.gif) | ![Pagination with placeholders](./docs/images/placeholders.gif) | 29 | 30 | For large screened devices: 31 | 32 | | [Pagination with adaptive layouts](https://tunjid.github.io/Tiler/usecases/adaptive-paging/) | [Pagination with adaptive layouts, search and placeholders](https://tunjid.github.io/Tiler/usecases/complex-tiling/) | 33 | |:--------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------------:| 34 | | ![Pagination with adaptive layouts](./docs/images/adaptive.gif) | ![Pagination with adaptive layouts, search and placeholders](./docs/images/complex.gif) | 35 | 36 | Please see the [documentation](https://tunjid.github.io/Tiler/) for more details. 37 | 38 | ## License 39 | 40 | Copyright 2021 Google LLC 41 | 42 | Licensed under the Apache License, Version 2.0 (the "License"); 43 | you may not use this file except in compliance with the License. 44 | You may obtain a copy of the License at 45 | 46 | https://www.apache.org/licenses/LICENSE-2.0 47 | 48 | Unless required by applicable law or agreed to in writing, software 49 | distributed under the License is distributed on an "AS IS" BASIS, 50 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 51 | See the License for the specific language governing permissions and 52 | limitations under the License. 53 | 54 | [badge-android]: http://img.shields.io/badge/-android-6EDB8D.svg?style=flat 55 | 56 | [badge-jvm]: http://img.shields.io/badge/-jvm-DB413D.svg?style=flat 57 | 58 | [badge-js]: http://img.shields.io/badge/-js-F8DB5D.svg?style=flat 59 | 60 | [badge-js-ir]: https://img.shields.io/badge/support-[IR]-AAC4E0.svg?style=flat 61 | 62 | [badge-nodejs]: https://img.shields.io/badge/-nodejs-68a063.svg?style=flat 63 | 64 | [badge-linux]: http://img.shields.io/badge/-linux-2D3F6C.svg?style=flat 65 | 66 | [badge-windows]: http://img.shields.io/badge/-windows-4D76CD.svg?style=flat 67 | 68 | [badge-wasm]: https://img.shields.io/badge/-wasm-624FE8.svg?style=flat 69 | 70 | [badge-apple-silicon]: http://img.shields.io/badge/support-[AppleSilicon]-43BBFF.svg?style=flat 71 | 72 | [badge-ios]: http://img.shields.io/badge/-ios-CDCDCD.svg?style=flat 73 | 74 | [badge-mac]: http://img.shields.io/badge/-macos-111111.svg?style=flat 75 | 76 | [badge-watchos]: http://img.shields.io/badge/-watchos-C0C0C0.svg?style=flat 77 | 78 | [badge-tvos]: http://img.shields.io/badge/-tvos-808080.svg?style=flat 79 | -------------------------------------------------------------------------------- /sample/android/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /sample/android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | id("android-application-convention") 19 | id("kotlin-android") 20 | alias(libs.plugins.compose.compiler) 21 | } 22 | 23 | android { 24 | defaultConfig { 25 | applicationId = "com.tunjid.tyler" 26 | targetSdk = 31 27 | versionCode = 1 28 | versionName = "1.0" 29 | 30 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 31 | } 32 | 33 | buildTypes { 34 | release { 35 | isMinifyEnabled = false 36 | proguardFiles( 37 | getDefaultProguardFile("proguard-android-optimize.txt"), 38 | "proguard-rules.pro" 39 | ) 40 | } 41 | } 42 | } 43 | 44 | dependencies { 45 | implementation(project(":sample:common")) 46 | 47 | implementation(libs.androidx.core.ktx) 48 | implementation(libs.androidx.appcompat) 49 | 50 | implementation(libs.androidx.activity.compose) 51 | implementation(libs.jetbrains.compose.material) 52 | implementation(libs.jetbrains.compose.animation) 53 | 54 | implementation(libs.google.material) 55 | } 56 | -------------------------------------------------------------------------------- /sample/android/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 -------------------------------------------------------------------------------- /sample/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 27 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.tyler 18 | 19 | import android.os.Bundle 20 | import androidx.activity.compose.setContent 21 | import androidx.appcompat.app.AppCompatActivity 22 | import com.tunjid.demo.common.ui.AppTheme 23 | import com.tunjid.demo.common.ui.Root 24 | 25 | class MainActivity : AppCompatActivity() { 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | setContent { 29 | AppTheme { 30 | Root() 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sample/android/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 24 | 25 | 31 | 34 | 37 | 38 | 39 | 40 | 46 | 47 | -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/android/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 32 | 33 | -------------------------------------------------------------------------------- /sample/android/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | #FFBB86FC 20 | #FF6200EE 21 | #FF3700B3 22 | #FF03DAC5 23 | #FF018786 24 | #FF000000 25 | #FFFFFFFF 26 | 27 | -------------------------------------------------------------------------------- /sample/android/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | Tyler 19 | 20 | -------------------------------------------------------------------------------- /sample/android/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 32 | 33 | -------------------------------------------------------------------------------- /sample/browser/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.jetbrains.compose.compose 18 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 19 | import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension 20 | 21 | plugins { 22 | kotlin("multiplatform") 23 | id("org.jetbrains.compose") 24 | alias(libs.plugins.compose.compiler) 25 | } 26 | 27 | kotlin { 28 | js(IR) { 29 | browser() 30 | binaries.executable() 31 | } 32 | sourceSets { 33 | val commonMain by getting { 34 | dependencies { 35 | implementation(project(":sample:common")) 36 | 37 | implementation(libs.kotlinx.coroutines.core) 38 | 39 | implementation(libs.tunjid.mutator.core.common) 40 | implementation(libs.tunjid.mutator.coroutines.common) 41 | } 42 | } 43 | 44 | val commonTest by getting { 45 | dependencies { 46 | } 47 | } 48 | 49 | val jsMain by getting { 50 | } 51 | } 52 | } 53 | 54 | compose.experimental { 55 | web.application {} 56 | } 57 | 58 | // a temporary workaround for a bug in jsRun invocation - see https://youtrack.jetbrains.com/issue/KT-48273 59 | afterEvaluate { 60 | rootProject.extensions.configure { 61 | versions.webpackDevServer.version = "4.0.0" 62 | versions.webpackCli.version = "4.10.0" 63 | nodeVersion = "16.0.0" 64 | } 65 | } 66 | 67 | 68 | // TODO: remove when https://youtrack.jetbrains.com/issue/KT-50778 fixed 69 | project.tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile::class.java).configureEach { 70 | kotlinOptions.freeCompilerArgs += listOf( 71 | "-Xir-dce-runtime-diagnostic=log" 72 | ) 73 | } -------------------------------------------------------------------------------- /sample/browser/resources/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | compose multiplatform web demo 22 | 23 | 24 | 25 | 26 |

compose multiplatform web demo

27 |
28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /sample/browser/resources/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | #root { 18 | width: 100%; 19 | height: 100vh; 20 | } 21 | 22 | #root > .compose-web-column > div { 23 | position: relative; 24 | } -------------------------------------------------------------------------------- /sample/browser/src/jsMain/kotlin/main.js.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import androidx.compose.foundation.layout.Column 18 | import androidx.compose.foundation.layout.fillMaxSize 19 | import androidx.compose.material.Button 20 | import androidx.compose.material.Text 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.window.Window 23 | import com.tunjid.demo.common.ui.AppTheme 24 | import com.tunjid.demo.common.ui.Root 25 | import org.jetbrains.skiko.wasm.onWasmReady 26 | 27 | 28 | fun main() { 29 | onWasmReady { 30 | Window("Tiler") { 31 | 32 | Column(modifier = Modifier.fillMaxSize()) { 33 | 34 | Button( 35 | onClick = { println("Clicked") }, 36 | content = { 37 | Text("HELLO") 38 | } 39 | ) 40 | 41 | AppTheme { 42 | Root() 43 | } 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /sample/browser/src/jsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | compose multiplatform web demo 22 | 23 | 24 | 25 | 26 |

compose multiplatform web demo

27 |
28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /sample/browser/src/jsMain/resources/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | #root { 18 | width: 100%; 19 | height: 100vh; 20 | } 21 | 22 | #root > .compose-web-column > div { 23 | position: relative; 24 | } -------------------------------------------------------------------------------- /sample/common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | plugins { 18 | id("android-library-convention") 19 | id("kotlin-library-convention") 20 | id("org.jetbrains.compose") 21 | alias(libs.plugins.compose.compiler) 22 | } 23 | 24 | kotlin { 25 | sourceSets { 26 | named("commonMain") { 27 | dependencies { 28 | implementation(project(":library:tiler")) 29 | implementation(project(":library:compose")) 30 | 31 | implementation(libs.jetbrains.compose.runtime) 32 | implementation(libs.jetbrains.compose.animation) 33 | implementation(libs.jetbrains.compose.material) 34 | implementation(libs.jetbrains.compose.foundation.layout) 35 | implementation(libs.jetbrains.compose.material.icons.core) 36 | implementation(libs.jetbrains.compose.material.icons.extended) 37 | 38 | implementation(libs.kotlinx.coroutines.core) 39 | 40 | api(libs.tunjid.mutator.core.common) 41 | api(libs.tunjid.mutator.coroutines.common) 42 | 43 | api(libs.tunjid.treenav.common) 44 | } 45 | } 46 | named("androidMain") { 47 | dependencies { 48 | } 49 | } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /sample/common/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 28 | 29 | 35 | 38 | 41 | 42 | 43 | 44 | 50 | 51 | -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/common/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | Tiler Demo 20 | 21 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/Root.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui 18 | 19 | import androidx.compose.foundation.isSystemInDarkTheme 20 | import androidx.compose.foundation.layout.Column 21 | import androidx.compose.foundation.pager.HorizontalPager 22 | import androidx.compose.foundation.pager.rememberPagerState 23 | import androidx.compose.material.Tab 24 | import androidx.compose.material.TabRow 25 | import androidx.compose.material.Text 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.rememberCoroutineScope 29 | import com.tunjid.demo.common.ui.numbers.Loader 30 | import com.tunjid.demo.common.ui.numbers.AdaptiveTiledGrid 31 | import com.tunjid.demo.common.ui.numbers.StickyHeaderTiledList 32 | import kotlinx.coroutines.CoroutineScope 33 | import kotlinx.coroutines.launch 34 | 35 | @Composable 36 | fun Root() { 37 | val numPages = 2 38 | val pagerState = rememberPagerState { 2 } 39 | val scope = rememberCoroutineScope() 40 | Column { 41 | TabRow(selectedTabIndex = pagerState.currentPage) { 42 | repeat(numPages) { page -> 43 | val title = when (page) { 44 | 0 -> "Adaptive Paging" 45 | 1 -> "Sticky Headers" 46 | else -> throw IllegalArgumentException() 47 | } 48 | Tab(text = { Text(title) }, 49 | selected = pagerState.currentPage == page, 50 | onClick = { 51 | scope.launch { 52 | pagerState.animateScrollToPage(page) 53 | } 54 | } 55 | ) 56 | } 57 | } 58 | HorizontalPager( 59 | state = pagerState, 60 | ) { page -> 61 | when (page) { 62 | 0 -> AdaptiveTiledGrid( 63 | loader = rememberLoader() 64 | ) 65 | 66 | 1 -> StickyHeaderTiledList( 67 | loader = rememberLoader() 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | 74 | @Composable 75 | fun rememberLoader( 76 | isDark: Boolean = isSystemInDarkTheme(), 77 | scope: CoroutineScope = rememberCoroutineScope() 78 | ) = remember { 79 | Loader( 80 | isDark = isDark, 81 | scope = scope 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui 18 | 19 | import androidx.compose.foundation.isSystemInDarkTheme 20 | import androidx.compose.material.MaterialTheme 21 | import androidx.compose.material.darkColors 22 | import androidx.compose.material.lightColors 23 | import androidx.compose.runtime.Composable 24 | 25 | private val DarkColorPalette = darkColors() 26 | 27 | private val LightColorPalette = lightColors() 28 | 29 | @Composable 30 | fun AppTheme( 31 | darkTheme: Boolean = isSystemInDarkTheme(), 32 | content: @Composable () -> Unit 33 | ) { 34 | val colors = if (darkTheme) { 35 | DarkColorPalette 36 | } else { 37 | LightColorPalette 38 | } 39 | 40 | MaterialTheme( 41 | colors = colors, 42 | content = content 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/AdaptiveTiledGrid.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui.numbers 18 | 19 | import androidx.compose.animation.animateContentSize 20 | import androidx.compose.foundation.layout.Row 21 | import androidx.compose.foundation.layout.padding 22 | import androidx.compose.foundation.lazy.grid.GridCells 23 | import androidx.compose.foundation.lazy.grid.GridItemSpan 24 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 25 | import androidx.compose.foundation.lazy.grid.items 26 | import androidx.compose.foundation.lazy.grid.rememberLazyGridState 27 | import androidx.compose.material.FloatingActionButton 28 | import androidx.compose.material.Icon 29 | import androidx.compose.material.Scaffold 30 | import androidx.compose.material.Text 31 | import androidx.compose.material.icons.Icons 32 | import androidx.compose.material.icons.filled.KeyboardArrowDown 33 | import androidx.compose.material.icons.filled.KeyboardArrowUp 34 | import androidx.compose.runtime.Composable 35 | import androidx.compose.runtime.collectAsState 36 | import androidx.compose.runtime.getValue 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.unit.dp 39 | import com.tunjid.tiler.compose.PivotedTilingEffect 40 | 41 | @Composable 42 | fun AdaptiveTiledGrid( 43 | loader: Loader 44 | ) { 45 | val state by loader.state.collectAsState() 46 | val isAscending = state.isAscending 47 | val tiledItems = state.items 48 | 49 | val lazyState = rememberLazyGridState() 50 | 51 | Scaffold( 52 | bottomBar = { 53 | TilingSummary(state.tilingSummary) 54 | }, 55 | floatingActionButton = { 56 | Fab( 57 | onClick = { loader.toggleOrder() }, 58 | isAscending = isAscending 59 | ) 60 | } 61 | ) { 62 | LazyVerticalGrid( 63 | state = lazyState, 64 | columns = GridCells.Adaptive(200.dp), 65 | content = { 66 | items( 67 | items = tiledItems, 68 | key = NumberTile::key, 69 | span = { 70 | loader.setNumberOfColumns(maxLineSpan) 71 | if (it.number % state.itemsPerPage == 0) GridItemSpan(maxLineSpan) 72 | else GridItemSpan(1) 73 | }, 74 | itemContent = { numberTile -> 75 | NumberTile( 76 | Modifier.animateItem(), 77 | numberTile 78 | ) 79 | } 80 | ) 81 | } 82 | ) 83 | } 84 | 85 | lazyState.PivotedTilingEffect( 86 | items = tiledItems, 87 | indexSelector = { start + (endInclusive - start) / 2 }, 88 | onQueryChanged = { if (it != null) loader.setCurrentPage(it.page) } 89 | ) 90 | } 91 | 92 | @Composable 93 | private fun Fab( 94 | onClick: () -> Unit, 95 | isAscending: Boolean 96 | ) { 97 | FloatingActionButton( 98 | onClick = { onClick() }, 99 | content = { 100 | Row( 101 | modifier = Modifier 102 | .padding(horizontal = 8.dp) 103 | .animateContentSize() 104 | ) { 105 | val text = if (isAscending) "Sort descending" else "Sort ascending" 106 | Text(text) 107 | when { 108 | isAscending -> Icon( 109 | imageVector = Icons.Default.KeyboardArrowDown, 110 | contentDescription = text 111 | ) 112 | 113 | else -> Icon( 114 | imageVector = Icons.Default.KeyboardArrowUp, 115 | contentDescription = text 116 | ) 117 | } 118 | } 119 | } 120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/Colors.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui.numbers 18 | 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.graphics.toArgb 21 | import kotlin.math.pow 22 | import kotlin.math.roundToInt 23 | import kotlin.random.Random 24 | 25 | object MutedColors { 26 | private val mutedColors = intArrayOf( 27 | Color(0xFF2980b9).toArgb(), // Belize Hole 28 | Color(0xFF2c3e50).toArgb(), // Midnight Blue 29 | Color(0xFFc0392b).toArgb(), // Pomegranate 30 | Color(0xFF16a085).toArgb(), // Green Sea 31 | Color(0xFF7f8c8d).toArgb() // Concrete 32 | ) 33 | 34 | private val darkerMutedColors = intArrayOf( 35 | Color(0xFF304233).toArgb(), 36 | Color(0xFF353b45).toArgb(), 37 | Color(0xFF392e3a).toArgb(), 38 | Color(0xFF3e2a2a).toArgb(), 39 | Color(0xFF474747).toArgb() 40 | ) 41 | 42 | fun colorAt(isDark: Boolean, index: Int) = palette(isDark).circular(index) 43 | 44 | fun random(isDark: Boolean): Int = palette(isDark).random() 45 | 46 | private fun palette(isDark: Boolean): IntArray = when (isDark) { 47 | true -> mutedColors 48 | else -> darkerMutedColors 49 | } 50 | } 51 | 52 | private fun IntArray.circular(index: Int) = this[index % size] 53 | 54 | private fun IntArray.random() = this[(Random.Default.nextInt(size))] 55 | 56 | fun interpolateColors(fraction: Float, startValue: Int, endValue: Int): Int { 57 | val startA = (startValue shr 24 and 0xff) / 255.0f 58 | var startR = (startValue shr 16 and 0xff) / 255.0f 59 | var startG = (startValue shr 8 and 0xff) / 255.0f 60 | var startB = (startValue and 0xff) / 255.0f 61 | val endA = (endValue shr 24 and 0xff) / 255.0f 62 | var endR = (endValue shr 16 and 0xff) / 255.0f 63 | var endG = (endValue shr 8 and 0xff) / 255.0f 64 | var endB = (endValue and 0xff) / 255.0f 65 | 66 | // convert from sRGB to linear 67 | startR = startR.toDouble().pow(2.2).toFloat() 68 | startG = startG.toDouble().pow(2.2).toFloat() 69 | startB = startB.toDouble().pow(2.2).toFloat() 70 | endR = endR.toDouble().pow(2.2).toFloat() 71 | endG = endG.toDouble().pow(2.2).toFloat() 72 | endB = endB.toDouble().pow(2.2).toFloat() 73 | 74 | // compute the interpolated color in linear space 75 | var a = startA + fraction * (endA - startA) 76 | var r = startR + fraction * (endR - startR) 77 | var g = startG + fraction * (endG - startG) 78 | var b = startB + fraction * (endB - startB) 79 | 80 | // convert back to sRGB in the [0..255] range 81 | a *= 255.0f 82 | r = r.toDouble().pow(1.0 / 2.2).toFloat() * 255.0f 83 | g = g.toDouble().pow(1.0 / 2.2).toFloat() * 255.0f 84 | b = b.toDouble().pow(1.0 / 2.2).toFloat() * 255.0f 85 | 86 | return a.roundToInt() shl 24 or (r.roundToInt() shl 16) or (g.roundToInt() shl 8) or b.roundToInt() 87 | } -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/NumberTile.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui.numbers 18 | 19 | import androidx.compose.foundation.BorderStroke 20 | import androidx.compose.foundation.layout.fillMaxWidth 21 | import androidx.compose.foundation.layout.padding 22 | import androidx.compose.material.Button 23 | import androidx.compose.material.ButtonDefaults 24 | import androidx.compose.material.Card 25 | import androidx.compose.material.MaterialTheme 26 | import androidx.compose.material.Text 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.graphics.Color 30 | import androidx.compose.ui.unit.dp 31 | 32 | data class NumberTile( 33 | val number: Int, 34 | val color: Int, 35 | ) 36 | val NumberTile.key get() = "tile-$number" 37 | 38 | @Composable 39 | fun NumberTile( 40 | modifier: Modifier = Modifier, 41 | numberTile: NumberTile 42 | ) { 43 | Button( 44 | modifier = modifier 45 | .fillMaxWidth() 46 | .padding(horizontal = 8.dp), 47 | elevation = ButtonDefaults.elevation(defaultElevation = 0.dp), 48 | border = BorderStroke(width = 2.dp, color = Color(numberTile.color)), 49 | colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.surface), 50 | onClick = { /*TODO*/ }, 51 | content = { 52 | Text( 53 | text = numberTile.number.toString(), 54 | color = Color(numberTile.color) 55 | ) 56 | } 57 | ) 58 | } 59 | 60 | @Composable 61 | fun TilingSummary(summary: String) { 62 | val modifier = Modifier 63 | .fillMaxWidth() 64 | .padding(horizontal = 16.dp, vertical = 8.dp) 65 | Card( 66 | modifier = modifier, 67 | ) { 68 | Text( 69 | modifier = modifier, 70 | text = summary 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/NumberUtilities.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui.numbers 18 | 19 | import kotlinx.coroutines.delay 20 | import kotlinx.coroutines.flow.Flow 21 | import kotlinx.coroutines.flow.flow 22 | import kotlinx.coroutines.flow.map 23 | 24 | fun Int.pageRange(itemsPerPage: Int): IntRange { 25 | val start = this * itemsPerPage 26 | return start.until(start + itemsPerPage) 27 | } 28 | 29 | fun PageQuery.colorShiftingTiles(itemsPerPage: Int, isDark: Boolean) = 30 | percentageAndIndex().map { (percentage, count) -> 31 | page.pageRange(itemsPerPage).mapIndexed { index, number -> 32 | NumberTile( 33 | number = number, 34 | color = interpolateColors( 35 | fraction = percentage, 36 | startValue = MutedColors.colorAt( 37 | isDark = isDark, 38 | index = index + count 39 | ), 40 | endValue = MutedColors.colorAt( 41 | isDark = isDark, 42 | index = index + count + 1 43 | ) 44 | ), 45 | ) 46 | } 47 | } 48 | .map { if (isAscending) it else it.asReversed() } 49 | 50 | private fun percentageAndIndex( 51 | changeDelayMillis: Long = 200L 52 | ): Flow> = flow { 53 | var percentage = 0f 54 | var index = 0 55 | 56 | while (true) { 57 | percentage += 0.05f 58 | if (percentage > 1f) { 59 | percentage = 0f 60 | index++ 61 | } 62 | 63 | emit(percentage to index) 64 | delay(changeDelayMillis) 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/numbers/StickyHeaderTiledList.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo.common.ui.numbers 18 | 19 | import androidx.compose.foundation.layout.padding 20 | import androidx.compose.foundation.lazy.LazyColumn 21 | import androidx.compose.foundation.lazy.items 22 | import androidx.compose.foundation.lazy.rememberLazyListState 23 | import androidx.compose.foundation.shape.RoundedCornerShape 24 | import androidx.compose.material.Card 25 | import androidx.compose.material.Scaffold 26 | import androidx.compose.material.Text 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.collectAsState 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.unit.dp 32 | import com.tunjid.tiler.compose.PivotedTilingEffect 33 | 34 | @Composable 35 | fun StickyHeaderTiledList( 36 | loader: Loader 37 | ) { 38 | val state by loader.state.collectAsState() 39 | val groupedTiledItems = state.groupedItems 40 | 41 | val lazyState = rememberLazyListState() 42 | 43 | Scaffold( 44 | bottomBar = { 45 | TilingSummary(state.tilingSummary) 46 | }, 47 | ) { 48 | LazyColumn( 49 | state = lazyState, 50 | content = { 51 | groupedTiledItems.forEach { (page, items) -> 52 | stickyHeader { 53 | PageHeader(page) 54 | } 55 | items( 56 | items = items, 57 | key = NumberTile::key, 58 | itemContent = { numberTile -> 59 | NumberTile( 60 | Modifier.animateItem(), 61 | numberTile 62 | ) 63 | } 64 | ) 65 | } 66 | } 67 | ) 68 | } 69 | 70 | lazyState.PivotedTilingEffect( 71 | items = state.items, 72 | indexSelector = { start + (endInclusive - start) / 2 }, 73 | onQueryChanged = { if (it != null) loader.setCurrentPage(it.page) } 74 | ) 75 | } 76 | 77 | @Composable 78 | private fun PageHeader(page: Int) { 79 | Card( 80 | shape = RoundedCornerShape( 81 | topEnd = 16.dp, 82 | bottomEnd = 16.dp 83 | ) 84 | ) { 85 | Text( 86 | modifier = Modifier 87 | .padding( 88 | vertical = 8.dp, 89 | horizontal = 16.dp 90 | ), 91 | text = "Page $page" 92 | ) 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /sample/common/src/iosMain/kotlin/main.ios.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.ComposeUIViewController 2 | import com.tunjid.demo.common.ui.AppTheme 3 | import com.tunjid.demo.common.ui.Root 4 | 5 | fun MainViewController() = ComposeUIViewController { 6 | AppTheme { 7 | Root() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /sample/desktop/build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 18 | 19 | plugins { 20 | kotlin("multiplatform") // kotlin("jvm") doesn't work well in IDEA/AndroidStudio (https://github.com/JetBrains/compose-jb/issues/22) 21 | id("org.jetbrains.compose") 22 | id("kotlin-jvm-convention") 23 | alias(libs.plugins.compose.compiler) 24 | } 25 | 26 | kotlin { 27 | jvm { 28 | withJava() 29 | } 30 | 31 | sourceSets { 32 | named("jvmMain") { 33 | dependencies { 34 | implementation(project(":sample:common")) 35 | 36 | implementation(compose.desktop.currentOs) 37 | 38 | implementation(libs.jetbrains.compose.material) 39 | implementation(libs.kotlinx.coroutines.core) 40 | 41 | implementation(libs.tunjid.mutator.core.jvm) 42 | implementation(libs.tunjid.mutator.coroutines.jvm) 43 | } 44 | } 45 | } 46 | } 47 | 48 | compose.desktop { 49 | application { 50 | mainClass = "com.tunjid.demo.MainKt" 51 | 52 | nativeDistributions { 53 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 54 | packageName = "Tiling" 55 | packageVersion = "1.0.0" 56 | 57 | windows { 58 | menuGroup = "Compose Examples" 59 | // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html 60 | upgradeUuid = "C2F20D8A-F643-4BB8-9ADD-28797B7514AF" 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.tunjid.demo 18 | 19 | import androidx.compose.ui.unit.DpSize 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.window.Window 22 | import androidx.compose.ui.window.application 23 | import androidx.compose.ui.window.rememberWindowState 24 | import com.tunjid.demo.common.ui.AppTheme 25 | import com.tunjid.demo.common.ui.Root 26 | 27 | fun main() { 28 | application { 29 | val windowState = rememberWindowState( 30 | size = DpSize(400.dp, 800.dp) 31 | ) 32 | Window( 33 | onCloseRequest = ::exitApplication, 34 | state = windowState, 35 | title = "Tiling Demo" 36 | ) { 37 | AppTheme { 38 | Root() 39 | } 40 | } 41 | } 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /sample/ios/tiler.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sample/ios/tiler.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /sample/ios/tiler.xcodeproj/project.xcworkspace/xcuserdata/adetunji_dahunsi.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tunjid/Tiler/59ed8d279713f8535b032ca6601eef29f8e3768c/sample/ios/tiler.xcodeproj/project.xcworkspace/xcuserdata/adetunji_dahunsi.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /sample/ios/tiler.xcodeproj/xcuserdata/adetunji_dahunsi.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | tiler.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /sample/ios/tiler/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample/ios/tiler/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/ios/tiler/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sample/ios/tiler/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // tiler 4 | // 5 | // Created by adetunji_dahunsi on 8/9/24. 6 | // 7 | import common 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | var body: some View { 12 | VStack { 13 | ComposeView() 14 | // Compose has own keyboard handler 15 | .ignoresSafeArea(edges: .bottom) 16 | } 17 | } 18 | } 19 | 20 | struct ComposeView: UIViewControllerRepresentable { 21 | func makeUIViewController(context: Context) -> UIViewController { 22 | let controller = Main_iosKt.MainViewController() 23 | controller.overrideUserInterfaceStyle = .light 24 | return controller 25 | } 26 | 27 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) { 28 | } 29 | } 30 | 31 | #Preview { 32 | ContentView() 33 | } 34 | -------------------------------------------------------------------------------- /sample/ios/tiler/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sample/ios/tiler/tilerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // tilerApp.swift 3 | // tiler 4 | // 5 | // Created by adetunji_dahunsi on 8/9/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct tilerApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | pluginManagement { 17 | includeBuild("build-logic") 18 | repositories { 19 | google() 20 | mavenCentral() 21 | gradlePluginPortal() 22 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 23 | } 24 | } 25 | dependencyResolutionManagement { 26 | // Workaround for KT-51379 27 | repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) 28 | repositories { 29 | google() 30 | mavenCentral() 31 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 32 | } 33 | } 34 | rootProject.name = "Tiling" 35 | include( 36 | ":library:tiler", 37 | ":library:compose", 38 | ":sample:common", 39 | ":sample:android", 40 | ":sample:desktop", 41 | // ":sample:browser", 42 | ":benchmarks:benchmarkable", 43 | ":benchmarks:microbenchmark", 44 | ) 45 | --------------------------------------------------------------------------------