├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── takwolf │ │ └── android │ │ └── demo │ │ └── refreshandloadmore │ │ ├── model │ │ ├── cnode │ │ │ ├── Author.kt │ │ │ ├── CNodeApi.kt │ │ │ ├── CNodeClient.kt │ │ │ ├── Result.kt │ │ │ └── Topic.kt │ │ ├── local │ │ │ ├── Page.kt │ │ │ └── Photo.kt │ │ └── zhihu │ │ │ ├── Story.kt │ │ │ ├── StoryPage.kt │ │ │ ├── ZhihuApi.kt │ │ │ └── ZhihuClient.kt │ │ ├── ui │ │ ├── activity │ │ │ ├── CNodeActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── PhotoListActivity.kt │ │ │ └── ZhihuActivity.kt │ │ ├── adapter │ │ │ ├── PhotoListAdapter.kt │ │ │ ├── StoryListAdapter.kt │ │ │ └── TopicListAdapter.kt │ │ └── widget │ │ │ └── LoadMoreFooter.kt │ │ ├── util │ │ ├── JsonUtils.kt │ │ ├── Time.kt │ │ ├── Toast.kt │ │ └── lifecycle │ │ │ ├── Event.kt │ │ │ └── Flow.kt │ │ └── vm │ │ ├── PhotoPagingViewModel.kt │ │ ├── StoryPagingViewModel.kt │ │ └── TopicPagingViewModel.kt │ └── res │ ├── drawable-xxxhdpi │ └── ic_topic_good.png │ ├── drawable │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.xml │ ├── layout │ ├── activity_demo.xml │ ├── activity_main.xml │ ├── footer_insets_navigation_bars.xml │ ├── footer_load_more.xml │ ├── item_photo.xml │ ├── item_story.xml │ └── item_topic.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | fail-fast: false 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup Java 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: "21" 19 | distribution: "temurin" 20 | - name: Build with Gradle 21 | run: ./gradlew build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://github.com/github/gitignore/blob/main/Android.gitignore 2 | 3 | # Gradle files 4 | .gradle/ 5 | build/ 6 | 7 | # Local configuration file (sdk path, etc) 8 | local.properties 9 | 10 | # Log/OS Files 11 | *.log 12 | 13 | # Android Studio generated files and folders 14 | captures/ 15 | .externalNativeBuild/ 16 | .cxx/ 17 | *.apk 18 | output.json 19 | 20 | # IntelliJ 21 | *.iml 22 | .idea/ 23 | misc.xml 24 | deploymentTargetDropDown.xml 25 | render.experimental.xml 26 | 27 | # Keystore files 28 | *.jks 29 | *.keystore 30 | 31 | # Google Services (e.g. APIs or Firebase) 32 | google-services.json 33 | 34 | # Android Profiling 35 | *.hprof 36 | 37 | # Kotlin 38 | .kotlin/ 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android - RefreshAndLoadMore Demo 2 | 3 | [![Platform](https://img.shields.io/badge/platform-Android-brightgreen)](https://developer.android.com) 4 | [![API](https://img.shields.io/badge/API-21%2B-brightgreen)](https://android-arsenal.com/api?level=21) 5 | [![License](https://img.shields.io/github/license/TakWolf/Android-RefreshAndLoadMore-Demo)](https://www.apache.org/licenses/LICENSE-2.0) 6 | 7 | 一种简单且优雅的下拉刷新和加载更多分页的解决方案。 8 | 9 | 应用架构符合 [Jetpack](https://developer.android.com/jetpack/getting-started) 。 10 | 11 | 下拉刷新基于 [SwipeRefreshLayout](https://developer.android.com/jetpack/androidx/releases/swiperefreshlayout) 实现。 12 | 13 | 加载更多基于 [HeaderAndFooterRecyclerView](https://github.com/TakWolf/Android-HeaderAndFooterRecyclerView) 中的 `LoadMoreFooter` 实现。 14 | 15 | 适配了沉浸式导航栏,使用了 [InsetsWidget](https://github.com/TakWolf/Android-InsetsWidget) 方案。 16 | 17 | 加载更多支持预载。并且数据填充不足一屏的情况,加载更多会自动触发。 18 | 19 | 具体思路和实现细节请参考程序。 20 | 21 | ![Demo](https://github.com/TakWolf/static.takwolf.com/blob/master/www/github/Android-RefreshAndLoadMore-Demo/01.gif) 22 | 23 | ## License 24 | 25 | ``` 26 | Copyright 2022 TakWolf 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 | http://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 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("com.google.devtools.ksp") 5 | } 6 | 7 | android { 8 | namespace = "com.takwolf.android.demo.refreshandloadmore" 9 | compileSdk = 36 10 | 11 | defaultConfig { 12 | applicationId = "com.takwolf.android.demo.refreshandloadmore" 13 | minSdk = 21 14 | targetSdk = 36 15 | versionCode = 1 16 | versionName = "0.0.1" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | isMinifyEnabled = false 22 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 23 | } 24 | } 25 | 26 | buildFeatures { 27 | viewBinding = true 28 | } 29 | 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.VERSION_1_8 32 | targetCompatibility = JavaVersion.VERSION_1_8 33 | isCoreLibraryDesugaringEnabled = true 34 | } 35 | 36 | kotlinOptions { 37 | jvmTarget = "1.8" 38 | } 39 | 40 | packaging { 41 | resources { 42 | excludes += "DebugProbesKt.bin" 43 | } 44 | } 45 | } 46 | 47 | dependencies { 48 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") 49 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") 50 | implementation("androidx.core:core-ktx:1.16.0") 51 | implementation("androidx.appcompat:appcompat:1.7.0") 52 | implementation("androidx.activity:activity-ktx:1.10.1") 53 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0") 54 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.9.0") 55 | implementation("androidx.recyclerview:recyclerview:1.4.0") 56 | implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") 57 | implementation("androidx.constraintlayout:constraintlayout:2.2.1") 58 | implementation("androidx.cardview:cardview:1.0.0") 59 | implementation("com.github.TakWolf.Android-HeaderAndFooterRecyclerView:hfrecyclerview:0.0.18") 60 | implementation("com.github.TakWolf.Android-HeaderAndFooterRecyclerView:paging:0.0.18") 61 | implementation("com.github.TakWolf.Android-InsetsWidget:insetswidget:0.0.1") 62 | implementation("de.hdodenhof:circleimageview:3.1.0") 63 | implementation("com.squareup.moshi:moshi:1.15.2") 64 | ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2") 65 | implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) 66 | implementation("com.squareup.okhttp3:okhttp") 67 | implementation("com.squareup.okhttp3:logging-interceptor") 68 | implementation("com.squareup.retrofit2:retrofit:2.11.0") 69 | implementation("com.squareup.retrofit2:converter-moshi:2.11.0") 70 | implementation("io.coil-kt.coil3:coil:3.0.4") 71 | implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4") 72 | } 73 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TakWolf/Android-RefreshAndLoadMore-Demo/fad5d43e447f4d315990717bd471fd05a1af9649/app/proguard-rules.pro -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/cnode/Author.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.cnode 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Author( 8 | @Json(name = "loginname") val loginName: String?, 9 | @Json(name = "avatar_url") val avatarUrl: String?, 10 | ) { 11 | val avatarUrlCompat: String? 12 | get() { 13 | return avatarUrl?.let { 14 | if (it.startsWith("//gravatar.com/avatar/")) { 15 | "https:${it}" 16 | } else { 17 | it 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/cnode/CNodeApi.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.cnode 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Query 5 | 6 | interface CNodeApi { 7 | @GET("topics") 8 | suspend fun getTopics( 9 | @Query("tab") tab: String? = null, 10 | @Query("page") page: Int = 1, 11 | @Query("limit") limit: Int = 20, 12 | @Query("mdrender") mdrender: Boolean = false, 13 | ): DataResult> 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/cnode/CNodeClient.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.cnode 2 | 3 | import com.takwolf.android.demo.refreshandloadmore.util.JsonUtils 4 | import okhttp3.OkHttpClient 5 | import okhttp3.logging.HttpLoggingInterceptor 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.moshi.MoshiConverterFactory 8 | 9 | object CNodeClient { 10 | val api: CNodeApi 11 | 12 | init { 13 | val retrofit = Retrofit.Builder() 14 | .baseUrl("https://cnodejs.org/api/v1/") 15 | .client(OkHttpClient.Builder() 16 | .addInterceptor(HttpLoggingInterceptor().apply { 17 | level = HttpLoggingInterceptor.Level.BODY 18 | }) 19 | .build()) 20 | .addConverterFactory(MoshiConverterFactory.create(JsonUtils.moshi)) 21 | .build() 22 | api = retrofit.create(CNodeApi::class.java) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/cnode/Result.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.cnode 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class DataResult( 8 | @Json(name = "success") val isSuccessful: Boolean, 9 | val data: Data, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/cnode/Topic.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.cnode 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import java.time.OffsetDateTime 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Topic( 9 | val id: String, 10 | @Json(name = "author_id") val authorId: String, 11 | val author: Author, 12 | val title: String, 13 | val tab: String?, 14 | @Json(name = "good") val isGood: Boolean, 15 | @Json(name = "top") val isTop: Boolean, 16 | val content: String, 17 | @Json(name = "visit_count") val visitCount: Int, 18 | @Json(name = "reply_count") val replyCount: Int, 19 | @Json(name = "create_at") val createAt: OffsetDateTime, 20 | @Json(name = "last_reply_at") val lastReplyAt: OffsetDateTime, 21 | ) { 22 | val tabDisplayString: String 23 | get() { 24 | return when (tab) { 25 | "share" -> "分享" 26 | "ask" -> "问答" 27 | "job" -> "招聘" 28 | "dev" -> "客户端测试" 29 | else -> "未知" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/local/Page.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.local 2 | 3 | data class Page( 4 | val list: List, 5 | val hasMore: Boolean, 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/local/Photo.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.local 2 | 3 | import kotlinx.coroutines.coroutineScope 4 | import kotlinx.coroutines.delay 5 | import java.util.UUID 6 | import kotlin.math.abs 7 | import kotlin.math.max 8 | import kotlin.random.Random 9 | 10 | data class Photo( 11 | val id: String, 12 | val url: String, 13 | ) { 14 | companion object { 15 | private val URLS = arrayOf( 16 | "https://static.takwolf.com/app-test/minami-kotori/0.jpg", 17 | "https://static.takwolf.com/app-test/minami-kotori/1.jpg", 18 | "https://static.takwolf.com/app-test/minami-kotori/2.jpg", 19 | "https://static.takwolf.com/app-test/minami-kotori/3.png", 20 | "https://static.takwolf.com/app-test/minami-kotori/4.png", 21 | "https://static.takwolf.com/app-test/minami-kotori/5.jpg", 22 | "https://static.takwolf.com/app-test/minami-kotori/6.jpg", 23 | "https://static.takwolf.com/app-test/minami-kotori/7.jpg", 24 | "https://static.takwolf.com/app-test/minami-kotori/8.png", 25 | "https://static.takwolf.com/app-test/minami-kotori/9.jpg", 26 | "https://static.takwolf.com/app-test/minami-kotori/10.jpg", 27 | "https://static.takwolf.com/app-test/minami-kotori/11.jpg", 28 | "https://static.takwolf.com/app-test/minami-kotori/12.png", 29 | "https://static.takwolf.com/app-test/minami-kotori/13.jpg", 30 | "https://static.takwolf.com/app-test/minami-kotori/14.jpg", 31 | "https://static.takwolf.com/app-test/minami-kotori/15.png", 32 | "https://static.takwolf.com/app-test/minami-kotori/16.jpg", 33 | "https://static.takwolf.com/app-test/minami-kotori/17.jpg", 34 | "https://static.takwolf.com/app-test/minami-kotori/18.png", 35 | "https://static.takwolf.com/app-test/minami-kotori/19.jpg", 36 | ) 37 | 38 | fun new(): Photo { 39 | return Photo(UUID.randomUUID().toString(), URLS[abs(Random.nextInt() % URLS.size)]) 40 | } 41 | 42 | fun newList(size: Int): List { 43 | return List(size) { new() } 44 | } 45 | 46 | suspend fun getPageAsync(pageNum: Int = 0, pageSize: Int = 20): Page = coroutineScope { 47 | delay(1000) 48 | val remaining = max(100 - pageNum * pageSize, 0) 49 | if (remaining > pageSize) { 50 | Page(newList(pageSize), true) 51 | } else { 52 | Page(newList(remaining), false) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/zhihu/Story.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.zhihu 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Story( 8 | val id: String, 9 | val title: String, 10 | val hint: String, 11 | val type: Int, 12 | val images: List?, 13 | @Json(name = "image_hue") val imageHue: String, 14 | val url: String, 15 | @Json(name = "ga_prefix") val gaPrefix: String, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/zhihu/StoryPage.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.zhihu 2 | 3 | import com.squareup.moshi.JsonClass 4 | 5 | @JsonClass(generateAdapter = true) 6 | data class StoryPage( 7 | val date: String, 8 | val stories: List, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/zhihu/ZhihuApi.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.zhihu 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Path 5 | 6 | interface ZhihuApi { 7 | @GET("stories/latest") 8 | suspend fun getLatestStories(): StoryPage 9 | 10 | @GET("stories/before/{date}") 11 | suspend fun getStoriesBefore(@Path("date") date: String): StoryPage 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/model/zhihu/ZhihuClient.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.model.zhihu 2 | 3 | import com.takwolf.android.demo.refreshandloadmore.util.JsonUtils 4 | import okhttp3.OkHttpClient 5 | import okhttp3.logging.HttpLoggingInterceptor 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.moshi.MoshiConverterFactory 8 | 9 | object ZhihuClient { 10 | val api: ZhihuApi 11 | 12 | init { 13 | val retrofit = Retrofit.Builder() 14 | .baseUrl("https://news-at.zhihu.com/api/4/") 15 | .client(OkHttpClient.Builder() 16 | .addInterceptor(HttpLoggingInterceptor().apply { 17 | level = HttpLoggingInterceptor.Level.BODY 18 | }) 19 | .build()) 20 | .addConverterFactory(MoshiConverterFactory.create(JsonUtils.moshi)) 21 | .build() 22 | api = retrofit.create(ZhihuApi::class.java) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/activity/CNodeActivity.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.activity 2 | 3 | import android.content.Intent 4 | import android.graphics.Color 5 | import android.os.Bundle 6 | import androidx.activity.SystemBarStyle 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.recyclerview.widget.LinearLayoutManager 11 | import com.takwolf.android.demo.refreshandloadmore.R 12 | import com.takwolf.android.demo.refreshandloadmore.databinding.ActivityDemoBinding 13 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.TopicListAdapter 14 | import com.takwolf.android.demo.refreshandloadmore.ui.widget.LoadMoreFooter 15 | import com.takwolf.android.demo.refreshandloadmore.vm.TopicPagingViewModel 16 | 17 | class CNodeActivity : AppCompatActivity() { 18 | companion object { 19 | fun open(activity: AppCompatActivity, notFullPage: Boolean = false) { 20 | val intent = Intent(activity, CNodeActivity::class.java).apply { 21 | putExtra("notFullPage", notFullPage) 22 | } 23 | activity.startActivity(intent) 24 | } 25 | } 26 | 27 | private val viewModel by viewModels() 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | enableEdgeToEdge( 31 | statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 32 | navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 33 | ) 34 | super.onCreate(savedInstanceState) 35 | val binding = ActivityDemoBinding.inflate(layoutInflater) 36 | setContentView(binding.root) 37 | 38 | binding.toolbar.setTitle(if (viewModel.notFullPage) R.string.cnode_not_full_page else R.string.cnode) 39 | binding.toolbar.setNavigationOnClickListener { 40 | finish() 41 | } 42 | 43 | binding.refreshLayout.setColorSchemeResources(R.color.app_primary) 44 | binding.recyclerView.layoutManager = LinearLayoutManager(this) 45 | val loadMoreFooter = LoadMoreFooter.create(binding.recyclerView).apply { 46 | addToRecyclerView(binding.recyclerView) 47 | } 48 | binding.recyclerView.addFooterView(R.layout.footer_insets_navigation_bars) 49 | val adapter = TopicListAdapter() 50 | binding.recyclerView.adapter = adapter 51 | viewModel.setupViews(this, binding.refreshLayout, loadMoreFooter, adapter) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.activity 2 | 3 | import android.graphics.Color 4 | import android.os.Bundle 5 | import androidx.activity.SystemBarStyle 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.takwolf.android.demo.refreshandloadmore.databinding.ActivityMainBinding 9 | 10 | class MainActivity : AppCompatActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | enableEdgeToEdge( 13 | statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 14 | navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT), 15 | ) 16 | super.onCreate(savedInstanceState) 17 | val binding = ActivityMainBinding.inflate(layoutInflater) 18 | setContentView(binding.root) 19 | 20 | binding.btnPhotoList.setOnClickListener { 21 | PhotoListActivity.open(this) 22 | } 23 | 24 | binding.btnPhotoListNotFullPage.setOnClickListener { 25 | PhotoListActivity.open(this, true) 26 | } 27 | 28 | binding.btnCnode.setOnClickListener { 29 | CNodeActivity.open(this) 30 | } 31 | 32 | binding.btnCnodeNotFullPage.setOnClickListener { 33 | CNodeActivity.open(this, true) 34 | } 35 | 36 | binding.btnZhihu.setOnClickListener { 37 | ZhihuActivity.open(this) 38 | } 39 | 40 | binding.btnZhihuNotFullPage.setOnClickListener { 41 | ZhihuActivity.open(this, true) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/activity/PhotoListActivity.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.activity 2 | 3 | import android.content.Intent 4 | import android.graphics.Color 5 | import android.os.Bundle 6 | import androidx.activity.SystemBarStyle 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.recyclerview.widget.LinearLayoutManager 11 | import com.takwolf.android.demo.refreshandloadmore.R 12 | import com.takwolf.android.demo.refreshandloadmore.databinding.ActivityDemoBinding 13 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.PhotoListAdapter 14 | import com.takwolf.android.demo.refreshandloadmore.ui.widget.LoadMoreFooter 15 | import com.takwolf.android.demo.refreshandloadmore.vm.PhotoPagingViewModel 16 | 17 | class PhotoListActivity : AppCompatActivity() { 18 | companion object { 19 | fun open(activity: AppCompatActivity, notFullPage: Boolean = false) { 20 | val intent = Intent(activity, PhotoListActivity::class.java).apply { 21 | putExtra("notFullPage", notFullPage) 22 | } 23 | activity.startActivity(intent) 24 | } 25 | } 26 | 27 | private val viewModel by viewModels() 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | enableEdgeToEdge( 31 | statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 32 | navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 33 | ) 34 | super.onCreate(savedInstanceState) 35 | val binding = ActivityDemoBinding.inflate(layoutInflater) 36 | setContentView(binding.root) 37 | 38 | binding.toolbar.setTitle(if (viewModel.notFullPage) R.string.photo_list_not_full_page else R.string.photo_list) 39 | binding.toolbar.setNavigationOnClickListener { 40 | finish() 41 | } 42 | 43 | binding.refreshLayout.setColorSchemeResources(R.color.app_primary) 44 | binding.recyclerView.layoutManager = LinearLayoutManager(this) 45 | val loadMoreFooter = LoadMoreFooter.create(binding.recyclerView).apply { 46 | addToRecyclerView(binding.recyclerView) 47 | } 48 | binding.recyclerView.addFooterView(R.layout.footer_insets_navigation_bars) 49 | val adapter = PhotoListAdapter() 50 | binding.recyclerView.adapter = adapter 51 | viewModel.setupViews(this, binding.refreshLayout, loadMoreFooter, adapter) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/activity/ZhihuActivity.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.activity 2 | 3 | import android.content.Intent 4 | import android.graphics.Color 5 | import android.os.Bundle 6 | import androidx.activity.SystemBarStyle 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.recyclerview.widget.LinearLayoutManager 11 | import com.takwolf.android.demo.refreshandloadmore.R 12 | import com.takwolf.android.demo.refreshandloadmore.databinding.ActivityDemoBinding 13 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.StoryListAdapter 14 | import com.takwolf.android.demo.refreshandloadmore.ui.widget.LoadMoreFooter 15 | import com.takwolf.android.demo.refreshandloadmore.vm.StoryPagingViewModel 16 | 17 | class ZhihuActivity : AppCompatActivity() { 18 | companion object { 19 | fun open(activity: AppCompatActivity, notFullPage: Boolean = false) { 20 | val intent = Intent(activity, ZhihuActivity::class.java).apply { 21 | putExtra("notFullPage", notFullPage) 22 | } 23 | activity.startActivity(intent) 24 | } 25 | } 26 | 27 | private val viewModel by viewModels() 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | enableEdgeToEdge( 31 | statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 32 | navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 33 | ) 34 | super.onCreate(savedInstanceState) 35 | val binding = ActivityDemoBinding.inflate(layoutInflater) 36 | setContentView(binding.root) 37 | 38 | binding.toolbar.setTitle(if (viewModel.notFullPage) R.string.zhihu_not_full_page else R.string.zhihu) 39 | binding.toolbar.setNavigationOnClickListener { 40 | finish() 41 | } 42 | 43 | binding.refreshLayout.setColorSchemeResources(R.color.app_primary) 44 | binding.recyclerView.layoutManager = LinearLayoutManager(this) 45 | val loadMoreFooter = LoadMoreFooter.create(binding.recyclerView).apply { 46 | addToRecyclerView(binding.recyclerView) 47 | } 48 | binding.recyclerView.addFooterView(R.layout.footer_insets_navigation_bars) 49 | val adapter = StoryListAdapter() 50 | binding.recyclerView.adapter = adapter 51 | viewModel.setupViews(this, binding.refreshLayout, loadMoreFooter, adapter) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/adapter/PhotoListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import coil3.load 9 | import coil3.request.error 10 | import coil3.request.placeholder 11 | import com.takwolf.android.demo.refreshandloadmore.R 12 | import com.takwolf.android.demo.refreshandloadmore.databinding.ItemPhotoBinding 13 | import com.takwolf.android.demo.refreshandloadmore.model.local.Photo 14 | 15 | class PhotoListAdapter : ListAdapter(PhotoDiffItemCallback) { 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 17 | val binding = ItemPhotoBinding.inflate(LayoutInflater.from(parent.context), parent, false) 18 | return ViewHolder(binding) 19 | } 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | holder.bind(getItem(position)) 23 | } 24 | 25 | class ViewHolder( 26 | private val binding: ItemPhotoBinding, 27 | ) : RecyclerView.ViewHolder(binding.root) { 28 | fun bind(photo: Photo) { 29 | binding.imgPhoto.load(photo.url) { 30 | placeholder(R.color.image_placeholder) 31 | error(R.color.image_placeholder) 32 | } 33 | } 34 | } 35 | } 36 | 37 | private object PhotoDiffItemCallback : DiffUtil.ItemCallback() { 38 | override fun areItemsTheSame(oldItem: Photo, newItem: Photo): Boolean { 39 | return oldItem.id == newItem.id 40 | } 41 | 42 | override fun areContentsTheSame(oldItem: Photo, newItem: Photo): Boolean { 43 | return oldItem == newItem 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/adapter/StoryListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.core.view.isVisible 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.ListAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import coil3.load 10 | import coil3.request.error 11 | import coil3.request.placeholder 12 | import com.takwolf.android.demo.refreshandloadmore.R 13 | import com.takwolf.android.demo.refreshandloadmore.databinding.ItemStoryBinding 14 | import com.takwolf.android.demo.refreshandloadmore.model.zhihu.Story 15 | 16 | class StoryListAdapter : ListAdapter(StoryDiffItemCallback) { 17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 18 | val binding = ItemStoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) 19 | return ViewHolder(binding) 20 | } 21 | 22 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 23 | holder.bind(getItem(position)) 24 | } 25 | 26 | class ViewHolder( 27 | private val binding: ItemStoryBinding, 28 | ) : RecyclerView.ViewHolder(binding.root) { 29 | fun bind(story: Story) { 30 | binding.tvTitle.text = story.title 31 | binding.tvHint.text = story.hint 32 | story.images?.also { images -> 33 | binding.imgPhoto.isVisible = true 34 | binding.imgPhoto.load(images[0]) { 35 | placeholder(R.color.image_placeholder) 36 | error(R.color.image_placeholder) 37 | } 38 | } ?: run { 39 | binding.imgPhoto.isVisible = false 40 | } 41 | } 42 | } 43 | } 44 | 45 | private object StoryDiffItemCallback : DiffUtil.ItemCallback() { 46 | override fun areItemsTheSame(oldItem: Story, newItem: Story): Boolean { 47 | return oldItem.id == newItem.id 48 | } 49 | 50 | override fun areContentsTheSame(oldItem: Story, newItem: Story): Boolean { 51 | return oldItem == newItem 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/adapter/TopicListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.core.view.isVisible 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.ListAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import coil3.load 10 | import com.takwolf.android.demo.refreshandloadmore.R 11 | import com.takwolf.android.demo.refreshandloadmore.databinding.ItemTopicBinding 12 | import com.takwolf.android.demo.refreshandloadmore.model.cnode.Topic 13 | import com.takwolf.android.demo.refreshandloadmore.util.timeSpanStringFromNow 14 | 15 | class TopicListAdapter : ListAdapter(TopicDiffItemCallback) { 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 17 | val binding = ItemTopicBinding.inflate(LayoutInflater.from(parent.context), parent, false) 18 | return ViewHolder(binding) 19 | } 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | holder.bind(getItem(position)) 23 | } 24 | 25 | class ViewHolder( 26 | private val binding: ItemTopicBinding, 27 | ) : RecyclerView.ViewHolder(binding.root) { 28 | fun bind(topic: Topic) { 29 | val resources = itemView.resources 30 | binding.imgGood.isVisible = topic.isGood 31 | binding.tvTop.isVisible = topic.isTop 32 | binding.tvTab.isVisible = !topic.isTop 33 | binding.tvTab.text = topic.tabDisplayString 34 | binding.tvReplyAndVisitCount.text = resources.getString(R.string.d_reply_d_visit, topic.replyCount, topic.visitCount) 35 | binding.tvReplyTime.text = resources.getString(R.string.reply_at_s, topic.lastReplyAt.timeSpanStringFromNow(resources)) 36 | binding.tvTitle.text = topic.title 37 | binding.tvSummary.text = topic.content 38 | binding.imgAuthor.load(topic.author.avatarUrlCompat) 39 | binding.tvAuthor.text = topic.author.loginName 40 | binding.tvCreateTime.text = resources.getString(R.string.create_at_s, topic.createAt.timeSpanStringFromNow(resources)) 41 | } 42 | } 43 | } 44 | 45 | private object TopicDiffItemCallback : DiffUtil.ItemCallback() { 46 | override fun areItemsTheSame(oldItem: Topic, newItem: Topic): Boolean { 47 | return oldItem.id == newItem.id 48 | } 49 | 50 | override fun areContentsTheSame(oldItem: Topic, newItem: Topic): Boolean { 51 | return oldItem == newItem 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/ui/widget/LoadMoreFooter.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.ui.widget 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import com.takwolf.android.demo.refreshandloadmore.R 6 | import com.takwolf.android.demo.refreshandloadmore.databinding.FooterLoadMoreBinding 7 | import com.takwolf.android.hfrecyclerview.HeaderAndFooterRecyclerView 8 | import com.takwolf.android.hfrecyclerview.paging.LoadMoreState 9 | 10 | class LoadMoreFooter private constructor( 11 | private val binding: FooterLoadMoreBinding, 12 | ) : com.takwolf.android.hfrecyclerview.paging.LoadMoreFooter(binding.root) { 13 | companion object { 14 | fun create(recyclerView: HeaderAndFooterRecyclerView): LoadMoreFooter { 15 | val binding = FooterLoadMoreBinding.inflate(LayoutInflater.from(recyclerView.context), recyclerView.footerViewContainer, false) 16 | return LoadMoreFooter(binding) 17 | } 18 | } 19 | 20 | init { 21 | binding.tvText.setOnClickListener { 22 | checkDoLoadMore() 23 | } 24 | preloadOffset = 1 25 | } 26 | 27 | override fun onUpdateViews() { 28 | when (state) { 29 | LoadMoreState.DISABLED -> { 30 | binding.loadingBar.visibility = View.INVISIBLE 31 | binding.tvText.visibility = View.INVISIBLE 32 | binding.tvText.text = null 33 | binding.tvText.isClickable = false 34 | } 35 | LoadMoreState.IDLE -> { 36 | binding.loadingBar.visibility = View.INVISIBLE 37 | binding.tvText.visibility = View.VISIBLE 38 | binding.tvText.text = null 39 | binding.tvText.isClickable = true 40 | } 41 | LoadMoreState.LOADING -> { 42 | binding.loadingBar.visibility = View.VISIBLE 43 | binding.tvText.visibility = View.INVISIBLE 44 | binding.tvText.text = null 45 | binding.tvText.isClickable = false 46 | } 47 | LoadMoreState.FINISHED -> { 48 | binding.loadingBar.visibility = View.INVISIBLE 49 | binding.tvText.visibility = View.VISIBLE 50 | binding.tvText.setText(R.string.load_more_finished) 51 | binding.tvText.isClickable = false 52 | } 53 | LoadMoreState.FAILED -> { 54 | binding.loadingBar.visibility = View.INVISIBLE 55 | binding.tvText.visibility = View.VISIBLE 56 | binding.tvText.setText(R.string.load_more_failed) 57 | binding.tvText.isClickable = true 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/util/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.util 2 | 3 | import com.squareup.moshi.FromJson 4 | import com.squareup.moshi.Moshi 5 | import com.squareup.moshi.ToJson 6 | import java.time.OffsetDateTime 7 | 8 | object JsonUtils { 9 | val moshi: Moshi = Moshi.Builder() 10 | .add(OffsetDateTimeJsonAdapter()) 11 | .build() 12 | } 13 | 14 | private class OffsetDateTimeJsonAdapter { 15 | @FromJson 16 | fun fromJson(iso8601: String): OffsetDateTime { 17 | return OffsetDateTime.parse(iso8601) 18 | } 19 | 20 | @ToJson 21 | fun toJson(offsetDateTime: OffsetDateTime): String { 22 | return offsetDateTime.toString() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/util/Time.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.util 2 | 3 | import android.content.res.Resources 4 | import com.takwolf.android.demo.refreshandloadmore.R 5 | import java.time.Duration 6 | import java.time.OffsetDateTime 7 | import java.time.format.DateTimeFormatter 8 | 9 | private const val MINUTE = 60 * 1000L 10 | private const val HOUR = 60 * MINUTE 11 | private const val DAY = 24 * HOUR 12 | private const val WEEK = 7 * DAY 13 | private const val MONTH = 31 * DAY 14 | private const val YEAR = 12 * MONTH 15 | 16 | private val displayDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") 17 | 18 | fun OffsetDateTime.timeSpanStringFromNow(resources: Resources): String { 19 | val offset = Duration.between(this, OffsetDateTime.now()).toMillis() 20 | return when { 21 | offset > YEAR -> { 22 | format(displayDateTimeFormatter) 23 | } 24 | offset > MONTH -> { 25 | resources.getString(R.string.d_months_ago, offset / MONTH) 26 | } 27 | offset > WEEK -> { 28 | resources.getString(R.string.d_weeks_ago, offset / WEEK) 29 | } 30 | offset > DAY -> { 31 | resources.getString(R.string.d_days_ago, offset / DAY) 32 | } 33 | offset > HOUR -> { 34 | resources.getString(R.string.d_hours_ago, offset / HOUR) 35 | } 36 | offset > MINUTE -> { 37 | resources.getString(R.string.d_minutes_ago, offset / MINUTE) 38 | } 39 | else -> { 40 | resources.getString(R.string.just_now) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/util/Toast.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.util 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | import java.lang.ref.WeakReference 6 | 7 | private var currentToast: WeakReference? = null 8 | 9 | fun Context.showToast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) { 10 | currentToast?.get()?.cancel() 11 | Toast.makeText(applicationContext, text, duration).apply { 12 | show() 13 | currentToast = WeakReference(this) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/util/lifecycle/Event.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.util.lifecycle 2 | 3 | open class Event(private val value: T) { 4 | private val consumers = mutableSetOf() 5 | 6 | fun isHandled(consumer: String? = null): Boolean { 7 | return consumers.contains(consumer) 8 | } 9 | 10 | fun handleValue(consumer: String? = null): T? { 11 | return if (isHandled(consumer)) { 12 | null 13 | } else { 14 | consumers.add(consumer) 15 | value 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/util/lifecycle/Flow.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.util.lifecycle 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.lifecycleScope 6 | import androidx.lifecycle.repeatOnLifecycle 7 | import kotlinx.coroutines.flow.FlowCollector 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.launch 10 | 11 | fun StateFlow.observe(owner: LifecycleOwner, collector: FlowCollector) { 12 | owner.lifecycleScope.launch { 13 | owner.repeatOnLifecycle(Lifecycle.State.STARTED) { 14 | collect(collector) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/vm/PhotoPagingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.vm 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.SavedStateHandle 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 8 | import com.takwolf.android.demo.refreshandloadmore.model.local.Photo 9 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.PhotoListAdapter 10 | import com.takwolf.android.demo.refreshandloadmore.util.lifecycle.observe 11 | import com.takwolf.android.hfrecyclerview.paging.LoadMoreFooter 12 | import com.takwolf.android.hfrecyclerview.paging.PagingSource 13 | import com.takwolf.android.hfrecyclerview.paging.observe 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.launch 16 | 17 | class PhotoPagingViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { 18 | val notFullPage = savedStateHandle.get("notFullPage") ?: false 19 | 20 | private val photos = MutableStateFlow(emptyList()) 21 | 22 | private var pageNum = -1 23 | 24 | private val pagingSource = object : PagingSource() { 25 | override fun doRefresh(dataVersion: Int) { 26 | viewModelScope.launch { 27 | val page = Photo.getPageAsync(pageSize = if (notFullPage) 1 else 20) 28 | if (onRefreshSuccess(dataVersion, !page.hasMore)) { 29 | photos.value = page.list 30 | pageNum = 0 31 | } 32 | } 33 | } 34 | 35 | override fun doLoadMore(dataVersion: Int) { 36 | viewModelScope.launch { 37 | val page = Photo.getPageAsync(pageNum + 1, if (notFullPage) 1 else 20) 38 | if (onLoadMoreSuccess(dataVersion, !page.hasMore)) { 39 | photos.value += page.list 40 | pageNum += 1 41 | } 42 | } 43 | } 44 | } 45 | 46 | init { 47 | pagingSource.refresh() 48 | } 49 | 50 | fun setupViews( 51 | owner: LifecycleOwner, 52 | refreshLayout: SwipeRefreshLayout, 53 | loadMoreFooter: LoadMoreFooter, 54 | adapter: PhotoListAdapter, 55 | ) { 56 | refreshLayout.setOnRefreshListener { 57 | pagingSource.refresh() 58 | } 59 | loadMoreFooter.onLoadMoreListener = LoadMoreFooter.OnLoadMoreListener { 60 | pagingSource.loadMore() 61 | } 62 | pagingSource.refreshState.observe(owner, refreshLayout) 63 | pagingSource.loadMoreState.observe(owner, loadMoreFooter) 64 | photos.observe(owner) { photos -> 65 | adapter.submitList(photos) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/vm/StoryPagingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.vm 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.SavedStateHandle 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 9 | import com.takwolf.android.demo.refreshandloadmore.model.zhihu.Story 10 | import com.takwolf.android.demo.refreshandloadmore.model.zhihu.ZhihuClient 11 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.StoryListAdapter 12 | import com.takwolf.android.demo.refreshandloadmore.util.lifecycle.Event 13 | import com.takwolf.android.demo.refreshandloadmore.util.lifecycle.observe 14 | import com.takwolf.android.demo.refreshandloadmore.util.showToast 15 | import com.takwolf.android.hfrecyclerview.paging.LoadMoreFooter 16 | import com.takwolf.android.hfrecyclerview.paging.PagingSource 17 | import com.takwolf.android.hfrecyclerview.paging.observe 18 | import kotlinx.coroutines.flow.MutableStateFlow 19 | import kotlinx.coroutines.launch 20 | 21 | class StoryPagingViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { 22 | val notFullPage = savedStateHandle.get("notFullPage") ?: false 23 | 24 | private val stories = MutableStateFlow(emptyList()) 25 | private val errorEvent = MutableLiveData>() 26 | 27 | private var date = "" 28 | 29 | private val pagingSource = object : PagingSource() { 30 | override fun doRefresh(dataVersion: Int) { 31 | viewModelScope.launch { 32 | try { 33 | val page = ZhihuClient.api.getLatestStories() 34 | if (onRefreshSuccess(dataVersion, false)) { 35 | stories.value = if (notFullPage) listOf(page.stories[0]) else page.stories 36 | date = page.date 37 | } 38 | } catch (e: Exception) { 39 | if (onRefreshFailure(dataVersion)) { 40 | errorEvent.value = Event(e.message ?: "refresh error") 41 | } 42 | } 43 | } 44 | } 45 | 46 | override fun doLoadMore(dataVersion: Int) { 47 | viewModelScope.launch { 48 | try { 49 | val page = ZhihuClient.api.getStoriesBefore(date) 50 | if (onLoadMoreSuccess(dataVersion, false)) { 51 | stories.value += if (notFullPage) listOf(page.stories[0]) else page.stories 52 | date = page.date 53 | } 54 | } catch (e: Exception) { 55 | if (onLoadMoreFailure(dataVersion)) { 56 | errorEvent.value = Event(e.message ?: "load more error") 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | init { 64 | pagingSource.refresh() 65 | } 66 | 67 | fun setupViews( 68 | activity: AppCompatActivity, 69 | refreshLayout: SwipeRefreshLayout, 70 | loadMoreFooter: LoadMoreFooter, 71 | adapter: StoryListAdapter, 72 | ) { 73 | refreshLayout.setOnRefreshListener { 74 | pagingSource.refresh() 75 | } 76 | loadMoreFooter.onLoadMoreListener = LoadMoreFooter.OnLoadMoreListener { 77 | pagingSource.loadMore() 78 | } 79 | pagingSource.refreshState.observe(activity, refreshLayout) 80 | pagingSource.loadMoreState.observe(activity, loadMoreFooter) 81 | stories.observe(activity) { stories -> 82 | adapter.submitList(stories) 83 | } 84 | errorEvent.observe(activity) { event -> 85 | event.handleValue()?.let { message -> 86 | activity.showToast(message) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/com/takwolf/android/demo/refreshandloadmore/vm/TopicPagingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.takwolf.android.demo.refreshandloadmore.vm 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.SavedStateHandle 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 9 | import com.takwolf.android.demo.refreshandloadmore.model.cnode.CNodeClient 10 | import com.takwolf.android.demo.refreshandloadmore.model.cnode.Topic 11 | import com.takwolf.android.demo.refreshandloadmore.ui.adapter.TopicListAdapter 12 | import com.takwolf.android.demo.refreshandloadmore.util.lifecycle.Event 13 | import com.takwolf.android.demo.refreshandloadmore.util.lifecycle.observe 14 | import com.takwolf.android.demo.refreshandloadmore.util.showToast 15 | import com.takwolf.android.hfrecyclerview.paging.LoadMoreFooter 16 | import com.takwolf.android.hfrecyclerview.paging.PagingSource 17 | import com.takwolf.android.hfrecyclerview.paging.observe 18 | import kotlinx.coroutines.flow.MutableStateFlow 19 | import kotlinx.coroutines.launch 20 | 21 | class TopicPagingViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { 22 | val notFullPage = savedStateHandle.get("notFullPage") ?: false 23 | 24 | private val topics = MutableStateFlow(emptyList()) 25 | private val errorEvent = MutableLiveData>() 26 | 27 | private var page = 0 28 | 29 | private val pagingSource = object : PagingSource() { 30 | override fun doRefresh(dataVersion: Int) { 31 | viewModelScope.launch { 32 | try { 33 | val result = CNodeClient.api.getTopics(limit = if (notFullPage) 1 else 20) 34 | if (onRefreshSuccess(dataVersion, result.data.isEmpty())) { 35 | topics.value = result.data 36 | page = 1 37 | } 38 | } catch (e: Exception) { 39 | if (onRefreshFailure(dataVersion)) { 40 | errorEvent.value = Event(e.message ?: "refresh error") 41 | } 42 | } 43 | } 44 | } 45 | 46 | override fun doLoadMore(dataVersion: Int) { 47 | viewModelScope.launch { 48 | try { 49 | val result = CNodeClient.api.getTopics(page = page + 1, limit = if (notFullPage) 1 else 20) 50 | if (onLoadMoreSuccess(dataVersion, result.data.isEmpty())) { 51 | topics.value += result.data 52 | page += 1 53 | } 54 | } catch (e: Exception) { 55 | if (onLoadMoreFailure(dataVersion)) { 56 | errorEvent.value = Event(e.message ?: "load more error") 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | init { 64 | pagingSource.refresh() 65 | } 66 | 67 | fun setupViews( 68 | activity: AppCompatActivity, 69 | refreshLayout: SwipeRefreshLayout, 70 | loadMoreFooter: LoadMoreFooter, 71 | adapter: TopicListAdapter, 72 | ) { 73 | refreshLayout.setOnRefreshListener { 74 | pagingSource.refresh() 75 | } 76 | loadMoreFooter.onLoadMoreListener = LoadMoreFooter.OnLoadMoreListener { 77 | pagingSource.loadMore() 78 | } 79 | pagingSource.refreshState.observe(activity, refreshLayout) 80 | pagingSource.loadMoreState.observe(activity, loadMoreFooter) 81 | topics.observe(activity) { topics -> 82 | adapter.submitList(topics) 83 | } 84 | errorEvent.observe(activity) { event -> 85 | event.handleValue()?.let { message -> 86 | activity.showToast(message) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_topic_good.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TakWolf/Android-RefreshAndLoadMore-Demo/fad5d43e447f4d315990717bd471fd05a1af9649/app/src/main/res/drawable-xxxhdpi/ic_topic_good.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 16 | 19 | 22 | 23 | 24 | 25 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_demo.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 23 | 24 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 21 | 22 | 25 | 26 | 31 | 32 |