├── sample ├── .gitignore ├── src │ └── main │ │ ├── res │ │ ├── font │ │ │ ├── worksans_bold.ttf │ │ │ ├── worksans_light.ttf │ │ │ ├── worksans_medium.ttf │ │ │ ├── worksans_regular.ttf │ │ │ └── worksans_semibold.ttf │ │ ├── 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 │ │ ├── drawable-xxhdpi │ │ │ ├── attachment_1.jpg │ │ │ ├── attachment_2.jpg │ │ │ ├── attachment_3.jpg │ │ │ ├── attachment_4.jpg │ │ │ ├── attachment_5.jpg │ │ │ ├── attachment_6.jpg │ │ │ ├── avatar_mom.jpg │ │ │ ├── avatar_sandra.jpg │ │ │ ├── avatar_trevor.jpg │ │ │ ├── avatar_ali_connors.jpg │ │ │ ├── avatar_jerry_chang.jpg │ │ │ └── avatar_googleexpress.jpg │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── layout │ │ │ ├── include_thread_image_attachments.xml │ │ │ ├── list_item_image_attachment.xml │ │ │ ├── include_email_pdf_attachment.xml │ │ │ ├── include_email_calendar_invite.xml │ │ │ ├── include_email_shipping_update.xml │ │ │ ├── activity_inbox.xml │ │ │ ├── include_email_image_gallery.xml │ │ │ ├── list_email_thread.xml │ │ │ ├── activity_about.xml │ │ │ └── fragment_email_thread.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── drawable │ │ │ ├── ic_keyboard_arrow_down_24dp.xml │ │ │ ├── ic_close_24dp.xml │ │ │ ├── ic_event_24dp.xml │ │ │ ├── ic_attachment_24dp.xml │ │ │ ├── ic_github_24dp.xml │ │ │ ├── ic_settings_24dp.xml │ │ │ ├── ic_shopping_cart_24dp.xml │ │ │ ├── avd_edit_to_reply_all.xml │ │ │ ├── avd_reply_all_to_edit.xml │ │ │ └── ic_launcher_background.xml │ │ ├── animator │ │ │ └── thread_elevation_stateanimator.xml │ │ └── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ └── me │ │ │ └── saket │ │ │ └── inboxrecyclerview │ │ │ └── sample │ │ │ ├── Extensions.kt │ │ │ ├── Person.kt │ │ │ ├── inbox │ │ │ ├── EmailThreadClicked.kt │ │ │ ├── ImageAttachmentAdapter.kt │ │ │ ├── InboxActivity.kt │ │ │ └── ThreadsAdapter.kt │ │ │ ├── Attachment.kt │ │ │ ├── Email.kt │ │ │ ├── App.kt │ │ │ ├── EmailThread.kt │ │ │ ├── widgets │ │ │ └── Views.kt │ │ │ ├── about │ │ │ └── AboutActivity.kt │ │ │ ├── EmailRepository.kt │ │ │ └── email │ │ │ └── EmailThreadFragment.kt │ │ └── AndroidManifest.xml └── build.gradle ├── inboxrecyclerview ├── .gitignore ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── me │ │ │ └── saket │ │ │ └── inboxrecyclerview │ │ │ ├── page │ │ │ ├── InterceptorResult.kt │ │ │ ├── PageStateChangeCallbacks.kt │ │ │ ├── PullToCollapseListener.kt │ │ │ ├── SimplePageStateChangeCallbacks.kt │ │ │ ├── SimpleNestedScrollingParent3.kt │ │ │ ├── SimpleOnPullListener.kt │ │ │ ├── OnExpandablePagePullListener.kt │ │ │ ├── OnPullToCollapseInterceptor.kt │ │ │ ├── PageCollapseEligibilityHapticFeedback.kt │ │ │ ├── PullToCollapseTouchListener.kt │ │ │ ├── BaseExpandablePageLayout.kt │ │ │ ├── StandaloneExpandablePageLayout.kt │ │ │ └── PullToCollapseNestedScroller.kt │ │ │ ├── Timber.kt │ │ │ ├── animation │ │ │ ├── NoneAnimator.kt │ │ │ ├── transitionUtils.kt │ │ │ ├── ScaleExpandAnimator.kt │ │ │ ├── PageLocationChangeDetector.kt │ │ │ ├── SplitExpandAnimator.kt │ │ │ └── ItemExpandAnimator.kt │ │ │ ├── dimming │ │ │ ├── TintPainter.kt │ │ │ ├── AnimatedVisibilityColorDrawable.kt │ │ │ ├── ListAndPageDimPainter.kt │ │ │ └── DimPainter.kt │ │ │ ├── expander │ │ │ ├── AdapterIdBasedItemExpander.kt │ │ │ └── InboxItemExpander.kt │ │ │ ├── InternalPageCallbacks.kt │ │ │ ├── ScrollSuppressibleRecyclerView.kt │ │ │ ├── Views.kt │ │ │ ├── PullCollapsibleActivity.kt │ │ │ └── InboxRecyclerView.kt │ │ └── res │ │ └── values │ │ └── resources.xml ├── gradle.properties └── build.gradle ├── settings.gradle ├── docs ├── images │ ├── background_dim.gif │ ├── static_thumbnail.jpg │ └── status_bar_tint.gif ├── background_dim.md ├── page_callbacks.md ├── item_animators.md └── pull_to_collapse.md ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── gradle-mvn-push.gradle ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE.md /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /inboxrecyclerview/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':sample', ':inboxrecyclerview' 2 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/images/background_dim.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/docs/images/background_dim.gif -------------------------------------------------------------------------------- /docs/images/static_thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/docs/images/static_thumbnail.jpg -------------------------------------------------------------------------------- /docs/images/status_bar_tint.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/docs/images/status_bar_tint.gif -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /sample/src/main/res/font/worksans_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/font/worksans_bold.ttf -------------------------------------------------------------------------------- /sample/src/main/res/font/worksans_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/font/worksans_light.ttf -------------------------------------------------------------------------------- /sample/src/main/res/font/worksans_medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/font/worksans_medium.ttf -------------------------------------------------------------------------------- /sample/src/main/res/font/worksans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/font/worksans_regular.ttf -------------------------------------------------------------------------------- /sample/src/main/res/font/worksans_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/font/worksans_semibold.ttf -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_1.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_2.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_3.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_4.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_5.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/attachment_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/attachment_6.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_mom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_mom.jpg -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_sandra.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_sandra.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_trevor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_trevor.jpg -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_ali_connors.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_ali_connors.jpg -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_jerry_chang.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_jerry_chang.jpg -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/Extensions.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | fun Unit.exhaustive() { 4 | // Nothing to do here. 5 | } 6 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxhdpi/avatar_googleexpress.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saket/InboxRecyclerView/HEAD/sample/src/main/res/drawable-xxhdpi/avatar_googleexpress.jpg -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/InterceptorResult.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | enum class InterceptResult { 4 | INTERCEPTED, 5 | IGNORED 6 | } 7 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/res/values/resources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 40dp 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/Person.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | import androidx.annotation.DrawableRes 4 | 5 | data class Person( 6 | val name: String, 7 | @DrawableRes val profileImageRes: Int? = null 8 | ) 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Aug 19 23:49:33 EDT 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip 7 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/inbox/EmailThreadClicked.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.inbox 2 | 3 | import me.saket.inboxrecyclerview.sample.EmailThread 4 | 5 | data class EmailThreadClicked( 6 | val thread: EmailThread, 7 | val itemId: Long 8 | ) 9 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/include_thread_image_attachments.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #344955 4 | #232F34 5 | #F8AA34 6 | #EEF0F2 7 | #FEFEFE 8 | 9 | -------------------------------------------------------------------------------- /sample/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2dp 4 | 18sp 5 | 26sp 6 | 7 | 88dp 8 | 9 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/Attachment.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | import androidx.annotation.DrawableRes 4 | 5 | sealed class Attachment { 6 | 7 | data class Image(@DrawableRes val drawableRes: Int) : Attachment() 8 | 9 | object Pdf : Attachment() 10 | 11 | object ShippingUpdate : Attachment() 12 | 13 | object CalendarEvent : Attachment() 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/list_item_image_attachment.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_keyboard_arrow_down_24dp.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/Email.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | data class Email( 4 | val body: String, 5 | val excerpt: String = body, 6 | val showBodyInThreads: Boolean = true, 7 | val recipients: List, 8 | val attachments: List = emptyList(), 9 | val timestamp: String 10 | ) { 11 | 12 | val hasImageAttachments = attachments.any { it is Attachment.Image } 13 | } 14 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/PageStateChangeCallbacks.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | /** 4 | * Implement this to receive state callbacks for [ExpandablePageLayout]. 5 | */ 6 | interface PageStateChangeCallbacks { 7 | 8 | fun onPageAboutToExpand(expandAnimDuration: Long) 9 | 10 | fun onPageExpanded() 11 | 12 | fun onPageAboutToCollapse(collapseAnimDuration: Long) 13 | 14 | fun onPageCollapsed() 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_close_24dp.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | InboxRecyclerView 3 | 4 | 5 | This sample app is a recreation of Reply, 6 | an app designed by Google as a case study for Material Design components and theming. All photos used in this 7 | app were taken from Unsplash. 8 | 9 | 10 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/PullToCollapseListener.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | class PullToCollapseListener { 4 | @Deprecated( 5 | message = "Moved to an upper level interface.", 6 | replaceWith = ReplaceWith( 7 | "OnExpandablePagePullListener", 8 | "me.saket.inboxrecyclerview.page.OnExpandablePagePullListener" 9 | ) 10 | ) 11 | interface OnPullListener : OnExpandablePagePullListener 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/App.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | import android.app.Application 4 | import com.squareup.leakcanary.LeakCanary 5 | import timber.log.Timber 6 | import timber.log.Timber.DebugTree 7 | 8 | class App : Application() { 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | if (LeakCanary.isInAnalyzerProcess(this)) { 13 | return 14 | } 15 | 16 | LeakCanary.install(this) 17 | Timber.plant(DebugTree()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_event_24dp.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/Timber.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.Log 5 | 6 | /** This class exists because I keep typing Timber.i() everywhere. */ 7 | internal object Timber { 8 | 9 | @SuppressLint("LogNotTimber") 10 | fun i(message: String) { 11 | if (BuildConfig.DEBUG) { 12 | Log.i("IRV", message) 13 | } 14 | } 15 | 16 | @SuppressLint("LogNotTimber") 17 | fun w(message: String) { 18 | if (BuildConfig.DEBUG) { 19 | Log.w("IRV", message) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_attachment_24dp.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | build/ 16 | 17 | # Gradle files 18 | .gradle/ 19 | build/ 20 | 21 | # Local configuration file (sdk path, etc) 22 | local.properties 23 | 24 | # Proguard folder generated by Eclipse 25 | proguard/ 26 | 27 | # Log Files 28 | *.log 29 | 30 | # Android Studio stuff 31 | .idea/ 32 | .navigation/ 33 | captures/ 34 | *.iml 35 | 36 | ### Android Patch ### 37 | gen-external-apklibs 38 | 39 | # OS specific ignores 40 | .DS_Store 41 | *~ 42 | *.swp 43 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/EmailThread.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | 5 | typealias EmailThreadId = Long 6 | 7 | data class EmailThread( 8 | val id: EmailThreadId, 9 | val sender: Person, 10 | val subject: String, 11 | val emails: List 12 | ) { 13 | 14 | class ItemDiffer : DiffUtil.ItemCallback() { 15 | override fun areItemsTheSame(oldItem: EmailThread, newItem: EmailThread) = oldItem.subject == newItem.subject 16 | override fun areContentsTheSame(oldItem: EmailThread, newItem: EmailThread) = oldItem == newItem 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/main/res/animator/thread_elevation_stateanimator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/widgets/Views.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.widgets 2 | 3 | import android.graphics.drawable.Drawable 4 | import android.widget.TextView 5 | import androidx.annotation.DrawableRes 6 | import androidx.core.content.ContextCompat 7 | 8 | fun TextView.setDrawableStart(@DrawableRes drawableRes: Int) { 9 | setDrawableStart(ContextCompat.getDrawable(context, drawableRes)) 10 | } 11 | 12 | fun TextView.setDrawableStart(drawable: Drawable?) { 13 | setCompoundDrawablesRelativeWithIntrinsicBounds( 14 | drawable, 15 | compoundDrawablesRelative[1], 16 | compoundDrawablesRelative[2], 17 | compoundDrawablesRelative[3] 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/NoneAnimator.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.view.View 4 | import me.saket.inboxrecyclerview.InboxRecyclerView 5 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 6 | 7 | /** 8 | * [https://www.youtube.com/watch?v=87ZbSFgwn8s] 9 | */ 10 | internal class NoneAnimator : ItemExpandAnimator() { 11 | override fun onPageMove( 12 | recyclerView: InboxRecyclerView, 13 | page: ExpandablePageLayout, 14 | anchorViewOverlay: View? 15 | ) { 16 | anchorViewOverlay?.alpha = 1f - page.contentOpacity 17 | } 18 | 19 | override fun resetAnimation( 20 | recyclerView: InboxRecyclerView, 21 | anchorViewOverlay: View? 22 | ) = Unit 23 | } 24 | -------------------------------------------------------------------------------- /inboxrecyclerview/gradle.properties: -------------------------------------------------------------------------------- 1 | VERSION_NAME=3.1.0-SNAPSHOT 2 | GROUP=me.saket 3 | 4 | POM_ARTIFACT_ID=inboxrecyclerview 5 | POM_NAME=InboxRecyclerView 6 | POM_PACKAGING=aar 7 | 8 | POM_DESCRIPTION=Library for building expandable descendant navigation 9 | POM_INCEPTION_YEAR=2018 10 | 11 | POM_URL=https://github.com/saket/InboxRecyclerView 12 | POM_SCM_URL=https://github.com/saket/InboxRecyclerView 13 | POM_SCM_CONNECTION=scm:git@github.com:saket/InboxRecyclerView.git 14 | POM_SCM_DEV_CONNECTION=scm:git@github.com:saket/InboxRecyclerView.git 15 | 16 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 17 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 18 | POM_LICENCE_DIST=repo 19 | 20 | POM_DEVELOPER_ID=saketme 21 | POM_DEVELOPER_NAME=Saket Narayan 22 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx1536m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | 19 | android.useAndroidX=true 20 | android.enableJetifier=true 21 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/SimplePageStateChangeCallbacks.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | /** 4 | * Empty implementations of [PageStateChangeCallbacks]. This way, any custom listener that 5 | * cares only about a subset of the methods of this listener can subclass this adapter 6 | * class instead of implementing the interface directly. 7 | */ 8 | abstract class SimplePageStateChangeCallbacks : PageStateChangeCallbacks { 9 | 10 | override fun onPageAboutToExpand(expandAnimDuration: Long) { 11 | // For rent. Broker free. 12 | } 13 | 14 | override fun onPageExpanded() { 15 | // For rent. Broker free. 16 | } 17 | 18 | override fun onPageAboutToCollapse(collapseAnimDuration: Long) { 19 | // For rent. Broker free. 20 | } 21 | 22 | override fun onPageCollapsed() { 23 | // For rent. Broker free. 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/background_dim.md: -------------------------------------------------------------------------------- 1 | # Background dim 2 | 3 | ![dim](images/background_dim.gif) 4 | 5 | `InboxRecyclerView` applies soft dimming on the list when it's covered by `ExpandablePageLayout`. The dimming is shifted to the page when it's pulled past the collapse threshold, as a visual indication that the page can now be released. 6 | 7 | By default, a black dim with an alpha of 15% is applied on the list. This can be customized by using, 8 | 9 | ```kotlin 10 | recyclerView.dimPainter = DimPainter.listAndPage(color = Color.WHITE, alpha = 0.65F) 11 | ``` 12 | 13 | If you wish to only apply dimming on the list, `DimPainter.listOnly(...)` can be used. 14 | 15 | It’s also encouraged that apps find other creative ways of communicating this to the user. [Dank](https://saket.me/dank), for example, uses the status bar color to indicate when the content is eligible for collapse. 16 | 17 | ![](images/status_bar_tint.gif) 18 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/dimming/TintPainter.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.dimming 2 | 3 | import android.graphics.Color 4 | 5 | object TintPainter { 6 | @JvmStatic 7 | @JvmOverloads 8 | @Deprecated( 9 | "Use listAndPage() instead", 10 | ReplaceWith("DimPainter.listAndPage(color, opacity)") 11 | ) 12 | fun uncoveredArea(color: Int = Color.BLACK, opacity: Float = 0.15F) = 13 | DimPainter.listAndPage(color, opacity) 14 | 15 | @JvmStatic 16 | @JvmOverloads 17 | @Deprecated( 18 | "No longer supported. Use listAndPage() instead", 19 | ReplaceWith("DimPainter.listAndPage(color, opacity)") 20 | ) 21 | fun completeList(color: Int = Color.BLACK, opacity: Float = 0.15F) = 22 | DimPainter.listAndPage(color, opacity) 23 | 24 | @JvmStatic 25 | @Deprecated("Use none() instead", ReplaceWith("DimPainter.none()")) 26 | fun noOp() = DimPainter.none() 27 | } -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/SimpleNestedScrollingParent3.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.view.View 4 | import androidx.core.view.NestedScrollingParent3 5 | 6 | /** 7 | * Provides default implementations of [NestedScrollingParent3]'s APIs that we don't care about. 8 | */ 9 | interface SimpleNestedScrollingParent3 : NestedScrollingParent3 { 10 | override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) = Unit 11 | 12 | override fun onNestedScroll( 13 | target: View, 14 | dxConsumed: Int, 15 | dyConsumed: Int, 16 | dxUnconsumed: Int, 17 | dyUnconsumed: Int, 18 | type: Int, 19 | consumed: IntArray 20 | ) = Unit 21 | 22 | override fun onNestedScroll( 23 | target: View, 24 | dxConsumed: Int, 25 | dyConsumed: Int, 26 | dxUnconsumed: Int, 27 | dyUnconsumed: Int, 28 | type: Int 29 | ) = Unit 30 | } 31 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/transitionUtils.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import java.lang.reflect.Method 6 | import kotlin.LazyThreadSafetyMode.NONE 7 | 8 | /** 9 | * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-master-dev:transition/transition/src/main/java/androidx/transition/TransitionUtils.java 10 | * @param forOverlayOf ViewGroup where the view copy be overlayed on. 11 | */ 12 | internal fun View.captureImage(forOverlayOf: ViewGroup): View { 13 | return Lazy.copyViewImage.invoke(null, forOverlayOf, this, parent as View) as View 14 | } 15 | 16 | private object Lazy { 17 | val copyViewImage: Method by lazy(NONE) { 18 | val utils = Class.forName("androidx.transition.TransitionUtils") 19 | utils.getDeclaredMethod( 20 | "copyViewImage", 21 | ViewGroup::class.java, View::class.java, View::class.java 22 | ).apply { isAccessible = true } 23 | } 24 | } -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_github_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/SimpleOnPullListener.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | /** 4 | * Empty implementations of [PullToCollapseListener.OnPullListener]. This way, any custom 5 | * listener that cares only about a subset of the methods of this listener can subclass 6 | * this adapter class instead of implementing the interface directly. 7 | */ 8 | @Deprecated( 9 | message = "Not needed anymore, thanks to default functions in interfaces", 10 | replaceWith = ReplaceWith( 11 | "OnExpandablePagePullListener", 12 | "me.saket.inboxrecyclerview.page.OnExpandablePagePullListener" 13 | ) 14 | ) 15 | abstract class SimpleOnPullListener : PullToCollapseListener.OnPullListener { 16 | /** 17 | * See [PullToCollapseListener.OnPullListener.onPull] 18 | */ 19 | override fun onPull( 20 | deltaY: Float, 21 | currentTranslationY: Float, 22 | upwardPull: Boolean, 23 | deltaUpwardPull: Boolean, 24 | collapseEligible: Boolean 25 | ) { 26 | // For rent. Broker free. 27 | } 28 | 29 | override fun onRelease(collapseEligible: Boolean) { 30 | // For rent. Broker free. 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /inboxrecyclerview/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | jcenter() 4 | } 5 | dependencies { 6 | classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:$versions.dokka" 7 | } 8 | } 9 | 10 | apply plugin: 'com.android.library' 11 | apply plugin: 'kotlin-android' 12 | apply plugin: 'kotlin-android-extensions' 13 | apply plugin: 'kotlin-kapt' 14 | apply plugin: 'org.jetbrains.dokka-android' 15 | 16 | android { 17 | compileSdkVersion versions.compileSdk 18 | resourcePrefix "irv_" 19 | 20 | defaultConfig { 21 | minSdkVersion 21 22 | targetSdkVersion versions.compileSdk 23 | } 24 | 25 | buildTypes { 26 | release { 27 | minifyEnabled false 28 | } 29 | } 30 | 31 | androidExtensions { 32 | experimental = true 33 | } 34 | 35 | kotlinOptions { 36 | jvmTarget = "1.8" 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation "androidx.appcompat:appcompat:$versions.androidx" 42 | implementation "androidx.core:core-ktx:1.3.1" 43 | implementation "androidx.recyclerview:recyclerview:1.1.0" 44 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin" 45 | } 46 | 47 | apply from: rootProject.file('gradle/gradle-mvn-push.gradle') 48 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_settings_24dp.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_shopping_cart_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 16 | 17 | 20 | 21 | 24 | 25 | 28 | 29 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/expander/AdapterIdBasedItemExpander.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.expander 2 | 3 | import android.os.Parcelable 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | class AdapterIdBasedItemExpander(private val requireStableIds: Boolean) : InboxItemExpander() { 9 | override fun identifyExpandingView( 10 | expandingItem: AdapterIdBasedItem, 11 | childViewHolders: Sequence 12 | ): ViewHolder? { 13 | val adapter = recyclerView.adapter!! 14 | check(requireStableIds && adapter.hasStableIds()) { 15 | "$adapter needs to have stable IDs so that the expanded item can be restored across " + 16 | "state restorations. If auto state restoration isn't needed, consider setting " + 17 | "InboxRecyclerView#itemExpander = AdapterIdBasedItemExpander(requireStableIds = false). " + 18 | "A custom InboxItemExpander can also be used for expanding items using custom " + 19 | "Parcelable types instead of adapter IDs." 20 | } 21 | return childViewHolders.firstOrNull { it.itemId == expandingItem.adapterId } 22 | } 23 | } 24 | 25 | @Parcelize 26 | data class AdapterIdBasedItem(val adapterId: Long) : Parcelable 27 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/OnExpandablePagePullListener.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | interface OnExpandablePagePullListener { 4 | /** 5 | * Called when a pull starts. 6 | */ 7 | fun onPullStarted() = Unit 8 | 9 | /** 10 | * Called when the user is pulling down / up the expandable page or the list. 11 | * 12 | * @param deltaY Delta translation-Y since the last onPull call. 13 | * @param currentTranslationY Current translation-Y of the page. 14 | * @param upwardPull Whether the page is being pulled in the upward direction. 15 | * @param deltaUpwardPull Whether the distance pulled since last the onPull() was made in the 16 | * upward direction. Calculated using [deltaY]. 17 | * @param collapseEligible Whether the pull distance is enough to trigger a collapse. 18 | */ 19 | fun onPull( 20 | deltaY: Float, 21 | currentTranslationY: Float, 22 | upwardPull: Boolean, 23 | deltaUpwardPull: Boolean, 24 | collapseEligible: Boolean 25 | ) = Unit 26 | 27 | /** 28 | * Called when the user's finger is lifted. 29 | * 30 | * @param collapseEligible Whether or not the pull distance was enough to trigger a collapse. 31 | */ 32 | fun onRelease(collapseEligible: Boolean) = Unit 33 | } 34 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/InternalPageCallbacks.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | /** Used internally, by [InboxRecyclerView]. */ 4 | internal interface InternalPageCallbacks { 5 | 6 | fun onPageAboutToExpand() = Unit 7 | 8 | /** 9 | * Called when this page has fully covered the list. This can happen in two situations: 10 | * 1. when the page has fully expanded. 11 | * 2. when the page has moved back to its position after being pulled. 12 | */ 13 | fun onPageFullyCovered() 14 | 15 | fun onPageAboutToCollapse() 16 | 17 | /** Page is no longer visible at this point. */ 18 | fun onPageCollapsed() 19 | 20 | /** Page will start getting pulled. */ 21 | fun onPagePullStarted() = Unit 22 | 23 | /** Page is being pulled. Sync the scroll with the list. */ 24 | fun onPagePull(deltaY: Float) 25 | 26 | /** Called when this page was released after being pulled. */ 27 | fun onPageRelease(collapseEligible: Boolean) 28 | 29 | class NoOp : InternalPageCallbacks { 30 | 31 | override fun onPageAboutToExpand() {} 32 | 33 | override fun onPageFullyCovered() {} 34 | 35 | override fun onPageAboutToCollapse() {} 36 | 37 | override fun onPageCollapsed() {} 38 | 39 | override fun onPagePull(deltaY: Float) {} 40 | 41 | override fun onPageRelease(collapseEligible: Boolean) {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/OnPullToCollapseInterceptor.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | /** 4 | * Called once every time a vertical scroll gesture is registered on [ExpandablePageLayout]. 5 | * When intercepted, all touch events until the finger is lifted will be ignored. This is 6 | * useful when nested (vertically) scrollable layouts are also present inside the page. 7 | * 8 | * Example usage: 9 | * 10 | * ``` 11 | * val directionInt = if (upwardPull) +1 else -1 12 | * val canScrollFurther = scrollableChild.canScrollVertically(directionInt) 13 | * return if (canScrollFurther) InterceptResult.INTERCEPTED else InterceptResult.IGNORED 14 | * ``` 15 | * 16 | * @param downX X-coordinate from where the gesture started, relative to the screen window. 17 | * @param downY Y-coordinate from where the gesture started, relative to the screen window. 18 | * @param upwardPull Upward pull == downward scroll and vice versa. 19 | * 20 | * @return True to consume this touch event. False otherwise. 21 | */ 22 | typealias OnPullToCollapseInterceptor = (downX: Float, downY: Float, upwardPull: Boolean) -> InterceptResult 23 | 24 | @Deprecated( 25 | message = "ExpandablePageLayout#pullToCollapseInterceptor is now nullable so this is no longer required", 26 | replaceWith = ReplaceWith("null") 27 | ) 28 | val IGNORE_ALL_PULL_TO_COLLAPSE_INTERCEPTOR : OnPullToCollapseInterceptor = { _, _, _ -> InterceptResult.IGNORED } 29 | -------------------------------------------------------------------------------- /docs/page_callbacks.md: -------------------------------------------------------------------------------- 1 | # Page callbacks 2 | 3 | `ExpandablePageLayout` has four different states. 4 | 5 | - `PageState.COLLAPSING` 6 | - `PageState.COLLAPSED` 7 | - `PageState.EXPANDING` 8 | - `PageState.EXPANDED` 9 | 10 | These can be accessed using `ExpandablePage#currentState` or by registering callbacks, 11 | 12 | ```kotlin 13 | expandablePage.addStateChangeCallbacks(object: SimplePageStateChangeCallbacks() { 14 | override fun onPageAboutToExpand(expandAnimDuration: Long) { 15 | override fun onPageExpanded() {} 16 | override fun onPageAboutToCollapse(collapseAnimDuration: Long) {} 17 | override fun onPageCollapsed() {} 18 | }) 19 | ``` 20 | 21 | #### Overridable functions 22 | `ExpandablePageLayout` offers the same set of callbacks as open functions that can be overridden when subclassed. This can also be useful for apps that use a View driven navigation stack instead of multiple Activities or Fragments. 23 | 24 | ```kotlin 25 | class Screen(context: Context) : ExpandablePageLayout(context) { 26 | override fun onPageAboutToExpand(expandAnimDuration: Long) {} 27 | override fun onPageExpanded() {} 28 | override fun onPageAboutToCollapse(collapseAnimDuration: Long) {} 29 | override fun onPageCollapsed() {} 30 | } 31 | ``` 32 | 33 | #### Pull-to-collapse gesture 34 | 35 | ```kotlin 36 | expandablePage.addOnPullListener(object: SimpleOnPullListener() { 37 | override fun onPull(...) {} 38 | override fun onRelease(collapseEligible: Boolean) {} 39 | }) 40 | ``` 41 | 42 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/ScrollSuppressibleRecyclerView.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | /** 8 | * Freezing layout using [setLayoutFrozen] isn't sufficient for blocking programmatic scrolls 9 | * i.e., scrolls not initiated by the user. These scrolls are either requested by the app or 10 | * by RV when a child View requests focus. 11 | * */ 12 | abstract class ScrollSuppressibleRecyclerView( 13 | context: Context, 14 | attrs: AttributeSet? 15 | ) : RecyclerView(context, attrs) { 16 | 17 | abstract fun canScrollProgrammatically(): Boolean 18 | 19 | override fun scrollToPosition(position: Int) { 20 | if (canScrollProgrammatically()) { 21 | super.scrollToPosition(position) 22 | } 23 | } 24 | 25 | override fun smoothScrollToPosition(position: Int) { 26 | if (canScrollProgrammatically()) { 27 | super.smoothScrollToPosition(position) 28 | } 29 | } 30 | 31 | override fun smoothScrollBy(dx: Int, dy: Int) { 32 | if (canScrollProgrammatically()) { 33 | super.smoothScrollBy(dx, dy) 34 | } 35 | } 36 | 37 | override fun scrollTo(x: Int, y: Int) { 38 | if (canScrollProgrammatically()) { 39 | super.scrollTo(x, y) 40 | } 41 | } 42 | 43 | override fun scrollBy(x: Int, y: Int) { 44 | if (canScrollProgrammatically()) { 45 | super.scrollBy(x, y) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/item_animators.md: -------------------------------------------------------------------------------- 1 | # Item Animators 2 | 3 | ```kotlin 4 | recyclerView.itemExpandAnimator = ItemExpandAnimator.split() / scale() / none() 5 | ``` 6 | 7 | `InboxRecyclerView` offers three kinds of animators for animating list items while the content is expanding, collapsing or being pulled. 8 | 9 | 1. Split [(video)](https://www.youtube.com/watch?v=WQGtweo-2dc) 10 | 2. Scale [(video)](https://www.youtube.com/watch?v=a0U8HcvT4G4) 11 | 3. None [(video)](https://www.youtube.com/watch?v=87ZbSFgwn8s) 12 | 13 | ### Customize 14 | By default, a duration of `350ms` and the `FastOutSlowInInterpolator()` interpolator are used for item animations. This can be changed using, 15 | 16 | ```kotlin 17 | pageLayout.animationDurationMillis = CUSTOM_DURATION 18 | pageLayout.animationInterpolator = CUSTOM_INTERPOLATOR 19 | ``` 20 | 21 | ### Make your own 22 | 23 | Custom animations can be written by extending [ItemExpandAnimator](https://github.com/saket/InboxRecyclerView/blob/master/expand/src/main/java/me/saket/expand/animation/ItemExpandAnimator.kt): 24 | 25 | ```kotlin 26 | recyclerView.itemExpandAnimator = object : ItemExpandAnimator() { 27 | override fun onPageMove( 28 | recyclerView: InboxRecyclerView, 29 | page: ExpandablePageLayout, 30 | anchorViewOverlay: View? 31 | ) { 32 | // This function gets called every time the page changes its position or size. 33 | // You'll want to describe frames of your animation here by syncing the position 34 | // of list items with the page. Avoid doing anything expensive, just like how 35 | // you'd treat onDraw(). 36 | } 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/pull_to_collapse.md: -------------------------------------------------------------------------------- 1 | # Pull to collapse 2 | 3 | ```kotlin 4 | pageLayout.pullToCollapseListener.apply { 5 | // Threshold distance for collapsing the page when 6 | // its pulled (upwards/downwards). Defaults to 56dp. 7 | collapseDistanceThreshold = THRESHOLD_IN_PIXELS 8 | 9 | // The page isn't moved with the same speed as the finger. Some friction 10 | // is applied to make the gesture feel nice. This friction is increased 11 | // further once the page is eligible for collapse as a visual indicator 12 | // that the page should no longer be dragged. Setting this to 1f will 13 | // remove the friction entirely. Defaults to 3.5f. 14 | pullFrictionFactor = 3.5f 15 | } 16 | ``` 17 | 18 | ### Intercepting pulls 19 | 20 | `ExpandablePageLayout` currently does not understand nested scrolling, so if the content contains scrollable child Views, the pull-to-collapse gesture will have to be intercepted manually. 21 | 22 | ```kotlin 23 | expandablePage.pullToCollapseInterceptor = { downX, downY, upwardPull -> 24 | val directionInt = if (upwardPull) +1 else -1 25 | val canScrollFurther = scrollableContainer.canScrollVertically(directionInt) 26 | if (canScrollFurther) InterceptResult.INTERCEPTED else InterceptResult.IGNORED 27 | } 28 | ``` 29 | 30 | When the scrollable children do not consume the entire space, the `downX` and `downY` parameters can be used to check if the touch actually lies on them. See the [sample app](https://github.com/saket/InboxRecyclerView/blob/cebf081d9398059ecaa9f04909ff3e9c48afd9cf/sample/src/main/java/me/saket/inboxrecyclerview/sample/email/EmailThreadFragment.kt#L65) for an example. 31 | -------------------------------------------------------------------------------- /sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 20 | 21 | 26 | 27 | 31 | 32 | 37 | 38 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/include_email_pdf_attachment.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 27 | 28 | 37 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/include_email_calendar_invite.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 27 | 28 | 36 | 37 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/about/AboutActivity.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.about 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.Intent.ACTION_VIEW 6 | import android.net.Uri 7 | import android.os.Bundle 8 | import android.view.View 9 | import android.widget.ImageButton 10 | import android.widget.TextView 11 | import kotterknife.bindView 12 | import me.saket.bettermovementmethod.BetterLinkMovementMethod 13 | import me.saket.inboxrecyclerview.PullCollapsibleActivity 14 | import me.saket.inboxrecyclerview.page.PageCollapseEligibilityHapticFeedback 15 | import me.saket.inboxrecyclerview.sample.R 16 | 17 | class AboutActivity : PullCollapsibleActivity() { 18 | 19 | private val navigationUpButton by bindView(R.id.about_navigation_up) 20 | private val bodyTextView by bindView(R.id.about_body) 21 | private val githubLinkView by bindView(R.id.about_github) 22 | 23 | companion object { 24 | fun intent(context: Context): Intent { 25 | return Intent(context, AboutActivity::class.java) 26 | } 27 | } 28 | 29 | @Suppress("DEPRECATION") 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | setContentView(R.layout.activity_about) 33 | activityPageLayout.addOnPullListener(PageCollapseEligibilityHapticFeedback(activityPageLayout)) 34 | 35 | navigationUpButton.setOnClickListener { 36 | finish() 37 | } 38 | 39 | bodyTextView.text = resources.getText(R.string.about_body).trim() 40 | BetterLinkMovementMethod.linkifyHtml(bodyTextView) 41 | 42 | githubLinkView.setOnClickListener { 43 | startActivity(Intent(ACTION_VIEW, Uri.parse("https://github.com/saket/inboxrecyclerview"))) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | repositories { 7 | maven { url "https://jitpack.io" } 8 | } 9 | 10 | android { 11 | compileSdkVersion versions.compileSdk 12 | 13 | defaultConfig { 14 | applicationId "me.saket.inboxrecyclerview.sample" 15 | minSdkVersion 23 16 | targetSdkVersion versions.compileSdk 17 | versionCode 1 18 | versionName "1.0" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | } 25 | } 26 | 27 | androidExtensions { 28 | experimental = true 29 | } 30 | 31 | kotlinOptions { 32 | jvmTarget = "1.8" 33 | } 34 | } 35 | 36 | dependencies { 37 | implementation project(path: ':inboxrecyclerview') 38 | 39 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$versions.kotlin" 40 | 41 | implementation "androidx.appcompat:appcompat:$versions.androidx" 42 | implementation "androidx.core:core-ktx:1.3.1" 43 | implementation "androidx.recyclerview:recyclerview:$versions.androidx" 44 | implementation 'com.google.android.material:material:1.0.0-rc01' 45 | 46 | implementation "com.jakewharton.timber:timber:$versions.timber" 47 | implementation('com.github.JakeWharton:kotterknife:e157638df1') { 48 | exclude group: 'com.android.support' 49 | } 50 | implementation 'io.reactivex.rxjava2:rxjava:2.1.9' 51 | implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' 52 | implementation 'com.jakewharton.rxrelay2:rxrelay:2.0.0' 53 | implementation 'de.hdodenhof:circleimageview:2.2.0' 54 | implementation 'me.saket:better-link-movement-method:2.2.0' 55 | debugImplementation "com.squareup.leakcanary:leakcanary-android:$versions.leakcanary" 56 | releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$versions.leakcanary" 57 | } 58 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/include_email_shipping_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 27 | 28 | 36 | 37 | 43 | 44 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/inbox/ImageAttachmentAdapter.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.inbox 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.MotionEvent 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.ImageView 9 | import androidx.core.content.ContextCompat 10 | import androidx.core.view.doOnLayout 11 | import androidx.core.view.doOnPreDraw 12 | import androidx.recyclerview.widget.RecyclerView 13 | import me.saket.inboxrecyclerview.sample.Attachment 14 | import me.saket.inboxrecyclerview.sample.R 15 | 16 | class ImageAttachmentAdapter( 17 | private val touchListener: (MotionEvent) -> Boolean 18 | ) : RecyclerView.Adapter() { 19 | 20 | var images: List = emptyList() 21 | 22 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { 23 | val imageLayout = LayoutInflater.from(parent.context).inflate(R.layout.list_item_image_attachment, parent, false) 24 | return ImageViewHolder(imageLayout) 25 | } 26 | 27 | override fun getItemCount() = images.size 28 | 29 | override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { 30 | holder.render(images[position], touchListener) 31 | } 32 | } 33 | 34 | @SuppressLint("ClickableViewAccessibility") 35 | class ImageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 36 | fun render(attachment: Attachment.Image, touchListener: (MotionEvent) -> Boolean) { 37 | val imageView = itemView as ImageView 38 | val image = ContextCompat.getDrawable(imageView.context, attachment.drawableRes)!! 39 | imageView.setImageDrawable(image) 40 | 41 | imageView.alpha = 0f 42 | imageView.animate().alpha(1f).setDuration(150) 43 | 44 | imageView.post { 45 | val resizedWidth = image.intrinsicWidth / (image.intrinsicHeight.toFloat() / imageView.height) 46 | val params = imageView.layoutParams 47 | params.width = resizedWidth.toInt() 48 | imageView.layoutParams = params 49 | } 50 | 51 | itemView.setOnTouchListener { _, event -> touchListener(event) } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/ScaleExpandAnimator.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.graphics.Canvas 4 | import android.view.View 5 | import androidx.core.graphics.withScale 6 | import me.saket.inboxrecyclerview.InboxRecyclerView 7 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 8 | 9 | /** 10 | * [https://www.youtube.com/watch?v=a0U8HcvT4G4] 11 | */ 12 | internal class ScaleExpandAnimator : ItemExpandAnimator() { 13 | private var unClippedScale: Float = 1f 14 | 15 | override fun onPageMove( 16 | recyclerView: InboxRecyclerView, 17 | page: ExpandablePageLayout, 18 | anchorViewOverlay: View? 19 | ) { 20 | if (!page.isMoving) { 21 | // Reset everything. This is also useful when the content size 22 | // changes, say as a result of the soft-keyboard getting dismissed. 23 | resetAnimation(recyclerView, anchorViewOverlay) 24 | return 25 | } 26 | 27 | val anchorY = recyclerView.expandedItemLoc.locationOnScreen.top 28 | val pageLocationOnScreen = page.locationOnScreen() 29 | val pageY = pageLocationOnScreen.top 30 | val pageTopYBound = pageY - page.translationY 31 | 32 | val expandRatio = (anchorY - pageY) / (anchorY - pageTopYBound) 33 | unClippedScale = 1f - (.10f * expandRatio.coerceIn(0.0f, 1.0f)) 34 | recyclerView.invalidate() 35 | 36 | // Fade in the anchor row with the expanding/collapsing page. 37 | anchorViewOverlay?.alpha = 1f - page.expandRatio(recyclerView) 38 | } 39 | 40 | override fun resetAnimation( 41 | recyclerView: InboxRecyclerView, 42 | anchorViewOverlay: View? 43 | ) { 44 | unClippedScale = 1f 45 | anchorViewOverlay?.alpha = 0f 46 | recyclerView.invalidate() 47 | } 48 | 49 | override fun transformRecyclerViewCanvas( 50 | recyclerView: InboxRecyclerView, 51 | canvas: Canvas, 52 | block: Canvas.() -> Unit 53 | ) { 54 | // Android clips canvas to the view's scaled bounds so a custom 55 | // scale property is used for drawing dimDrawable over full bounds. 56 | canvas.withScale(unClippedScale, unClippedScale, recyclerView.pivotX, recyclerView.pivotY) { 57 | block(canvas) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/dimming/AnimatedVisibilityColorDrawable.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.dimming 2 | 3 | import android.animation.ObjectAnimator 4 | import android.animation.ValueAnimator 5 | import android.graphics.drawable.ColorDrawable 6 | import android.view.animation.DecelerateInterpolator 7 | import androidx.annotation.IntRange 8 | 9 | /** 10 | * A Drawable that smoothly toggles its alpha between 0 and [maxAlpha]. 11 | * 12 | * @param onInvalidate Useful when this drawable isn't set as the background or the foreground 13 | * of a View because the View will otherwise not auto-invalidate itself when this Drawable's 14 | * alpha is updated. 15 | */ 16 | class AnimatedVisibilityColorDrawable( 17 | color: Int, 18 | @IntRange(from = 0, to = 255) private val maxAlpha: Int, 19 | private val animDuration: Long, 20 | private val onInvalidate: (() -> Unit)? = null 21 | ) : ColorDrawable(color) { 22 | 23 | private var alphaAnimator = ValueAnimator() 24 | var isShown: Boolean? = null 25 | 26 | init { 27 | setShown(false, immediately = true) 28 | } 29 | 30 | override fun invalidateSelf() { 31 | super.invalidateSelf() 32 | onInvalidate?.invoke() 33 | } 34 | 35 | // This name sounds a bit stupid, but I couldn't use setVisible(), 36 | // whose lifecycle is managed by the host View of this drawable. 37 | fun setShown(show: Boolean, immediately: Boolean = false) { 38 | val targetAlpha = if (show) maxAlpha else 0 39 | 40 | if (this.alpha == targetAlpha) return 41 | if (alphaAnimator.isRunning && isShown == show) return 42 | 43 | isShown = show 44 | 45 | if (immediately) { 46 | alpha = targetAlpha 47 | 48 | } else { 49 | alphaAnimator.cancel() 50 | alphaAnimator = ObjectAnimator.ofInt(super.getAlpha(), targetAlpha).apply { 51 | startDelay = 0 52 | duration = animDuration 53 | interpolator = DecelerateInterpolator() 54 | addUpdateListener { 55 | alpha = it.animatedValue as Int 56 | } 57 | start() 58 | } 59 | } 60 | } 61 | 62 | fun cancelAnimation(setAlphaTo: Int?) { 63 | alphaAnimator.cancel() 64 | 65 | if (setAlphaTo != null) { 66 | alpha = setAlphaTo 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/PageCollapseEligibilityHapticFeedback.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.view.HapticFeedbackConstants 4 | import me.saket.inboxrecyclerview.dimming.DimPainter 5 | 6 | /** 7 | * Plays haptic feedback during a pull-to-collapse gesture to indicate when the page can be 8 | * released to collapse. 9 | * 10 | * @param feedbackConstantForPull Haptic feedback played when the page is dragged enough to be 11 | * eligible for a collapse (if released). This is played again if the page is dragged back up. 12 | * 13 | * @param feedbackConstantForRelease Haptic feedback played when the page is released to collapse. 14 | */ 15 | class PageCollapseEligibilityHapticFeedback( 16 | private val page: ExpandablePageLayout, 17 | private val feedbackConstantForPull: Int = HapticFeedbackConstants.LONG_PRESS, 18 | private val feedbackConstantForRelease: Int = HapticFeedbackConstants.LONG_PRESS, 19 | private val minimumMillisBetweenPlays: Long = 100 20 | ) : OnExpandablePagePullListener { 21 | 22 | private var lastPlayedAt = 0L 23 | 24 | private val isCollapseEligible = OnChange(false) { 25 | lastPlayedAt = System.currentTimeMillis() 26 | page.performHapticFeedback(feedbackConstantForPull) 27 | } 28 | 29 | override fun onPull( 30 | deltaY: Float, 31 | currentTranslationY: Float, 32 | upwardPull: Boolean, 33 | deltaUpwardPull: Boolean, 34 | collapseEligible: Boolean 35 | ) { 36 | if (page.isExpanded) { // Page can be pulled while it's expanding. 37 | isCollapseEligible.value = collapseEligible 38 | } 39 | } 40 | 41 | override fun onRelease(collapseEligible: Boolean) { 42 | // Avoid playing overlapping haptic feedbacks, in case the page was pulled and collapsed quickly. 43 | if (collapseEligible && (System.currentTimeMillis() - lastPlayedAt) > minimumMillisBetweenPlays) { 44 | page.performHapticFeedback(feedbackConstantForRelease) 45 | isCollapseEligible.value = false 46 | } 47 | } 48 | } 49 | 50 | private class OnChange(defaultValue: T, val onChange: (value: T) -> Unit) { 51 | var value: T = defaultValue 52 | set(value) { 53 | if (field != value) { 54 | field = value 55 | onChange(value) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_inbox.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 25 | 26 | 33 | 34 | 43 | 44 | 45 | 53 | 54 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/Views.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.content.Context 6 | import android.graphics.Rect 7 | import android.view.View 8 | import android.view.ViewPropertyAnimator 9 | 10 | internal object Views { 11 | internal fun toolbarHeight(context: Context): Int { 12 | val typedArray = context.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) 13 | val standardToolbarHeight = typedArray.getDimensionPixelSize(0, 0) 14 | typedArray.recycle() 15 | return standardToolbarHeight 16 | } 17 | } 18 | 19 | internal fun ViewPropertyAnimator.withEndAction(action: (Boolean) -> Unit): ViewPropertyAnimator { 20 | return setListener(object : AnimatorListenerAdapter() { 21 | var canceled = false 22 | 23 | override fun onAnimationStart(animation: Animator) { 24 | canceled = false 25 | } 26 | 27 | override fun onAnimationCancel(animation: Animator) { 28 | canceled = true 29 | } 30 | 31 | override fun onAnimationEnd(animation: Animator) { 32 | action(canceled) 33 | } 34 | }) 35 | } 36 | 37 | internal fun View.locationOnScreen( 38 | intBuffer: IntArray = IntArray(2), 39 | rectBuffer: Rect = Rect(), 40 | ignoreTranslations: Boolean = false 41 | ): Rect { 42 | getLocationOnScreen(intBuffer) 43 | if (ignoreTranslations) { 44 | intBuffer[0] -= translationX.toInt() 45 | intBuffer[1] -= translationY.toInt() 46 | } 47 | rectBuffer.set(intBuffer[0], intBuffer[1], intBuffer[0] + width, intBuffer[1] + height) 48 | return rectBuffer 49 | } 50 | 51 | /** 52 | * View#doOnLayout() has a terrible gotcha that it gets called _during_ a layout 53 | * when isLaidOut resolves to true, causing nested onLayouts to possibly never execute. 54 | */ 55 | internal inline fun View.doOnLayout2(crossinline action: () -> Unit) { 56 | if (isInEditMode || isLaidOut || (width > 0 || height > 0)) { 57 | action() 58 | return 59 | } 60 | 61 | addOnLayoutChangeListener(object : View.OnLayoutChangeListener { 62 | override fun onLayoutChange( 63 | view: View, 64 | left: Int, 65 | top: Int, 66 | right: Int, 67 | bottom: Int, 68 | oldLeft: Int, 69 | oldTop: Int, 70 | oldRight: Int, 71 | oldBottom: Int 72 | ) { 73 | view.removeOnLayoutChangeListener(this) 74 | action() 75 | } 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/dimming/ListAndPageDimPainter.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.dimming 2 | 3 | import androidx.core.graphics.ColorUtils 4 | import me.saket.inboxrecyclerview.InboxRecyclerView 5 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 6 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout.PageState.COLLAPSED 7 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout.PageState.COLLAPSING 8 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout.PageState.EXPANDED 9 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout.PageState.EXPANDING 10 | 11 | /** 12 | * Draws dimming over [InboxRecyclerView] while the page is expanding/collapsing. 13 | * The dimming is shifted to the page when it's pulled past the collapse threshold, 14 | * as a visual indication that the page can now be released. 15 | */ 16 | internal class ListAndPageDimPainter( 17 | private val listDim: Dim, 18 | private val pageDim: Dim? 19 | ) : DimPainter() { 20 | 21 | override fun onPageMove(rv: InboxRecyclerView, page: ExpandablePageLayout) { 22 | if (rv.dimDrawable == null) { 23 | rv.dimDrawable = AnimatedVisibilityColorDrawable( 24 | color = listDim.color, 25 | maxAlpha = listDim.maxAlpha, 26 | animDuration = page.animationDurationMillis, 27 | onInvalidate = rv::invalidate 28 | ) 29 | } 30 | if (page.dimDrawable == null && pageDim != null) { 31 | page.dimDrawable = AnimatedVisibilityColorDrawable( 32 | color = pageDim.color, 33 | maxAlpha = pageDim.maxAlpha, 34 | animDuration = page.animationDurationMillis, 35 | onInvalidate = page::invalidate 36 | ) 37 | } 38 | 39 | rv.dimDrawable!!.setShown( 40 | when (page.currentState) { 41 | COLLAPSING, COLLAPSED -> false 42 | EXPANDING -> true 43 | EXPANDED -> !page.isCollapseEligible 44 | } 45 | ) 46 | 47 | if (pageDim != null) { 48 | page.dimDrawable!!.setShown( 49 | when (page.currentState) { 50 | COLLAPSING -> false 51 | COLLAPSED, EXPANDING -> false 52 | EXPANDED -> page.isCollapseEligible 53 | } 54 | ) 55 | } 56 | } 57 | 58 | override fun cancelAnimation( 59 | rv: InboxRecyclerView, 60 | page: ExpandablePageLayout, 61 | resetDim: Boolean 62 | ) { 63 | rv.dimDrawable?.cancelAnimation(setAlphaTo = if (resetDim) 0 else null) 64 | page.dimDrawable?.cancelAnimation(setAlphaTo = if (resetDim) 0 else null) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/PageLocationChangeDetector.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.graphics.Rect 4 | import android.view.ViewTreeObserver 5 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 6 | import me.saket.inboxrecyclerview.page.PageStateChangeCallbacks 7 | import me.saket.inboxrecyclerview.page.SimplePageStateChangeCallbacks 8 | 9 | /** 10 | * Gives a callback everytime [ExpandablePageLayout]'s size or location changes. 11 | * Can be used for, say, synchronizing animations with the page's expansion/collapse. 12 | */ 13 | class PageLocationChangeDetector( 14 | private val page: ExpandablePageLayout, 15 | private val changeListener: () -> Unit 16 | ) : ViewTreeObserver.OnPreDrawListener, ViewTreeObserver.OnGlobalLayoutListener, SimplePageStateChangeCallbacks() { 17 | 18 | private var lastTranslationX = 0F 19 | private var lastTranslationY = 0F 20 | private var lastWidth = 0 21 | private var lastHeight = 0 22 | private var lastClippedDimens = Rect() 23 | private var lastState = page.currentState 24 | 25 | override fun onPreDraw(): Boolean { 26 | dispatchCallbackIfNeeded() 27 | return true 28 | } 29 | 30 | override fun onGlobalLayout() { 31 | // Changes in the page's dimensions will get handled here. 32 | dispatchCallbackIfNeeded() 33 | } 34 | 35 | override fun onPageCollapsed() { 36 | // The page may get removed once it's collapsed. 37 | dispatchCallbackIfNeeded() 38 | } 39 | 40 | private fun dispatchCallbackIfNeeded() { 41 | val moved = lastTranslationX != page.translationX || lastTranslationY != page.translationY 42 | val stateChanged = lastState != page.currentState 43 | val dimensionsChanged = lastWidth != page.width 44 | || lastHeight != page.height 45 | || lastClippedDimens != page.clippedDimens 46 | 47 | if (moved || dimensionsChanged || stateChanged) { 48 | changeListener() 49 | } 50 | 51 | lastTranslationX = page.translationX 52 | lastTranslationY = page.translationY 53 | lastWidth = page.width 54 | lastHeight = page.height 55 | lastClippedDimens.set(page.clippedDimens) 56 | lastState = page.currentState 57 | } 58 | 59 | fun start() { 60 | page.viewTreeObserver.addOnGlobalLayoutListener(this) 61 | page.viewTreeObserver.addOnPreDrawListener(this) 62 | page.addStateChangeCallbacks(this) 63 | } 64 | 65 | fun stop() { 66 | page.viewTreeObserver.removeOnGlobalLayoutListener(this) 67 | page.viewTreeObserver.removeOnPreDrawListener(this) 68 | page.removeStateChangeCallbacks(this) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/include_email_image_gallery.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 20 | 21 | 29 | 30 | 31 | 35 | 36 | 43 | 44 | 52 | 53 | 61 | 62 | 63 | 73 | 74 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/SplitExpandAnimator.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.view.View 4 | import me.saket.inboxrecyclerview.InboxRecyclerView 5 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 6 | 7 | /** 8 | * [https://www.youtube.com/watch?v=WQGtweo-2dc] 9 | */ 10 | internal class SplitExpandAnimator : ItemExpandAnimator() { 11 | 12 | override fun onPageMove( 13 | recyclerView: InboxRecyclerView, 14 | page: ExpandablePageLayout, 15 | anchorViewOverlay: View? 16 | ) { 17 | if (!page.isMoving) { 18 | // Reset everything. This is also useful when the content size 19 | // changes, say as a result of the soft-keyboard getting dismissed. 20 | resetAnimation(recyclerView, anchorViewOverlay) 21 | return 22 | } 23 | 24 | val anchorIndex = recyclerView.expandedItemLoc.viewIndex 25 | val anchorViewLocation = recyclerView.expandedItemLoc.locationOnScreen 26 | 27 | val pageTop = page.locationOnScreen().top 28 | val pageBottom = pageTop + page.clippedDimens.height() 29 | 30 | // Move the RecyclerView rows with the page. 31 | if (anchorViewOverlay != null) { 32 | val distanceExpandedTowardsTop = pageTop - anchorViewLocation.top 33 | val distanceExpandedTowardsBottom = pageBottom - anchorViewLocation.bottom 34 | recyclerView.moveListItems(anchorIndex, distanceExpandedTowardsTop, distanceExpandedTowardsBottom) 35 | 36 | } else { 37 | // Anchor View can be null when the page was expanded from 38 | // an arbitrary location. See InboxRecyclerView#expandFromTop(). 39 | recyclerView.moveListItems(anchorIndex, 0, pageBottom - pageTop) 40 | } 41 | 42 | // Fade in the anchor row with the expanding/collapsing page. 43 | anchorViewOverlay?.alpha = 1f - page.expandRatio(recyclerView) 44 | } 45 | 46 | private fun InboxRecyclerView.moveListItems( 47 | anchorIndex: Int, 48 | distanceExpandedTowardsTop: Int, 49 | distanceExpandedTowardsBottom: Int 50 | ) { 51 | for (childIndex in 0 until childCount) { 52 | getChildAt(childIndex).translationY = when { 53 | childIndex <= anchorIndex -> distanceExpandedTowardsTop.toFloat() 54 | else -> distanceExpandedTowardsBottom.toFloat() 55 | } 56 | } 57 | } 58 | 59 | override fun resetAnimation( 60 | recyclerView: InboxRecyclerView, 61 | anchorViewOverlay: View? 62 | ) { 63 | for (childIndex in 0 until recyclerView.childCount) { 64 | val childView = recyclerView.getChildAt(childIndex) 65 | childView.translationY = 0F 66 | childView.alpha = 1F 67 | } 68 | anchorViewOverlay?.alpha = 0f 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/avd_edit_to_reply_all.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/avd_reply_all_to_edit.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/list_email_thread.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 26 | 27 | 39 | 40 | 50 | 51 | 66 | 67 | 75 | 76 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/PullToCollapseTouchListener.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.graphics.Rect 4 | import android.view.GestureDetector 5 | import android.view.GestureDetector.SimpleOnGestureListener 6 | import android.view.MotionEvent 7 | import android.view.MotionEvent.ACTION_CANCEL 8 | import android.view.MotionEvent.ACTION_DOWN 9 | import android.view.MotionEvent.ACTION_UP 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import androidx.core.view.ViewCompat.TYPE_TOUCH 13 | import androidx.core.view.children 14 | import kotlin.math.abs 15 | 16 | /** 17 | * Manually forwards events to [PullToCollapseNestedScroller] when [ExpandablePageLayout]'s 18 | * content isn't nested-scrollable. 19 | */ 20 | internal class PullToCollapseTouchListener( 21 | val page: ViewGroup, 22 | val nestedScroller: PullToCollapseNestedScroller 23 | ) : SimpleOnGestureListener() { 24 | 25 | private var canNestedScroll = true 26 | private val fakeIntArray = intArrayOf(0, 0) 27 | 28 | private val interceptDetector = GestureDetector(page.context, object : SimpleOnGestureListener() { 29 | override fun onScroll(down: MotionEvent, move: MotionEvent, distanceX: Float, distanceY: Float): Boolean { 30 | if (abs(distanceY) > abs(distanceX)) { 31 | nestedScroller.onStartNestedScroll(distanceY.toInt(), TYPE_TOUCH) 32 | } 33 | return nestedScroller.isNestedScrolling 34 | } 35 | }) 36 | 37 | private val scrollDetector = GestureDetector(page.context, object : SimpleOnGestureListener() { 38 | override fun onDown(e: MotionEvent?): Boolean { 39 | return true 40 | } 41 | 42 | override fun onScroll(down: MotionEvent, move: MotionEvent, distanceX: Float, distanceY: Float): Boolean { 43 | nestedScroller.onNestedPreScroll( 44 | target = null, 45 | dy = distanceY.toInt(), 46 | consumed = fakeIntArray, 47 | type = TYPE_TOUCH 48 | ) 49 | return abs(distanceY) > abs(distanceX) 50 | } 51 | }) 52 | 53 | fun onInterceptTouch(event: MotionEvent): Boolean { 54 | if (event.action == ACTION_DOWN) { 55 | canNestedScroll = page.findChildUnderTouch(event)?.isNestedScrollingEnabled == true 56 | scrollDetector.onTouchEvent(event) 57 | } 58 | return if (canNestedScroll) false else interceptDetector.onTouchEvent(event) 59 | } 60 | 61 | fun onTouch(event: MotionEvent): Boolean { 62 | if (event.action == ACTION_UP || event.action == ACTION_CANCEL) { 63 | nestedScroller.onStopNestedScroll(TYPE_TOUCH) 64 | } 65 | return scrollDetector.onTouchEvent(event) 66 | } 67 | 68 | private fun ViewGroup.findChildUnderTouch( 69 | e: MotionEvent, 70 | hitRect: Rect = Rect(), 71 | ): View? { 72 | for (child in children) { 73 | child.getHitRect(hitRect) 74 | hitRect.offset(this.left, this.top) 75 | val contains = hitRect.contains(e.x.toInt() + page.scrollX, e.y.toInt() + page.scrollY) 76 | 77 | if (contains) { 78 | if (child.isNestedScrollingEnabled) { 79 | return child 80 | } 81 | (child as? ViewGroup)?.findChildUnderTouch(e, hitRect)?.let { 82 | return it 83 | } 84 | return child 85 | } 86 | } 87 | return null 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/dimming/DimPainter.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.dimming 2 | 3 | import android.graphics.Color.BLACK 4 | import androidx.annotation.ColorInt 5 | import androidx.annotation.FloatRange 6 | import androidx.annotation.IntRange 7 | import me.saket.inboxrecyclerview.InboxRecyclerView 8 | import me.saket.inboxrecyclerview.animation.PageLocationChangeDetector 9 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 10 | 11 | /** 12 | * Draws dimming on [InboxRecyclerView] rows when they're covered by [ExpandablePageLayout]. 13 | * See [listAndPage]. 14 | */ 15 | abstract class DimPainter { 16 | private var onDetach: ((resetDim: Boolean) -> Unit)? = null 17 | 18 | fun onAttachRecyclerView( 19 | recyclerView: InboxRecyclerView, 20 | page: ExpandablePageLayout 21 | ) { 22 | val changeDetector = PageLocationChangeDetector(page) { 23 | onPageMove(recyclerView, page) 24 | } 25 | 26 | changeDetector.start() 27 | onDetach = { resetDim -> 28 | cancelAnimation(recyclerView, page, resetDim) 29 | changeDetector.stop() 30 | } 31 | } 32 | 33 | fun onDetachRecyclerView(resetDim: Boolean) { 34 | onDetach?.invoke(/* resetDim = */ resetDim ) 35 | } 36 | 37 | abstract fun cancelAnimation( 38 | rv: InboxRecyclerView, 39 | page: ExpandablePageLayout, 40 | resetDim: Boolean 41 | ) 42 | 43 | abstract fun onPageMove( 44 | rv: InboxRecyclerView, 45 | page: ExpandablePageLayout 46 | ) 47 | 48 | internal data class Dim( 49 | @ColorInt val color: Int, 50 | @IntRange(from = 0, to = 255) val maxAlpha: Int 51 | ) 52 | 53 | companion object { 54 | 55 | /** See [ListAndPageDimPainter]. */ 56 | @JvmStatic 57 | fun listAndPage( 58 | @ColorInt listColor: Int = BLACK, 59 | @FloatRange(from = 0.0, to = 1.0) listAlpha: Float = 0.15F, 60 | @ColorInt pageColor: Int = BLACK, 61 | @FloatRange(from = 0.0, to = 1.0) pageAlpha: Float = 0.15F 62 | ): DimPainter = 63 | ListAndPageDimPainter( 64 | listDim = Dim(listColor, (listAlpha * 255).toInt()), 65 | pageDim = Dim(pageColor, (pageAlpha * 255).toInt()) 66 | ) 67 | 68 | /** See [ListAndPageDimPainter]. */ 69 | @JvmStatic 70 | @JvmOverloads 71 | fun listAndPage( 72 | @ColorInt color: Int = BLACK, 73 | @FloatRange(from = 0.0, to = 1.0) alpha: Float = 0.15F 74 | ): DimPainter = listAndPage(color, alpha, color, alpha) 75 | 76 | /** See [ListAndPageDimPainter]. */ 77 | @JvmStatic 78 | @JvmOverloads 79 | fun listOnly( 80 | @ColorInt color: Int = BLACK, 81 | @FloatRange(from = 0.0, to = 1.0) alpha: Float = 0.15F 82 | ): DimPainter = ListAndPageDimPainter( 83 | listDim = Dim(color, (alpha * 255).toInt()), 84 | pageDim = null 85 | ) 86 | 87 | @JvmStatic 88 | fun none(): DimPainter { 89 | return object : DimPainter() { 90 | override fun cancelAnimation( 91 | rv: InboxRecyclerView, 92 | page: ExpandablePageLayout, 93 | resetDim: Boolean 94 | ) = Unit 95 | 96 | override fun onPageMove(rv: InboxRecyclerView, page: ExpandablePageLayout) = Unit 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 33 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 34 | 35 | @rem Find java.exe 36 | if defined JAVA_HOME goto findJavaFromJavaHome 37 | 38 | set JAVA_EXE=java.exe 39 | %JAVA_EXE% -version >NUL 2>&1 40 | if "%ERRORLEVEL%" == "0" goto init 41 | 42 | echo. 43 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 44 | echo. 45 | echo Please set the JAVA_HOME variable in your environment to match the 46 | echo location of your Java installation. 47 | 48 | goto fail 49 | 50 | :findJavaFromJavaHome 51 | set JAVA_HOME=%JAVA_HOME:"=% 52 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 53 | 54 | if exist "%JAVA_EXE%" goto init 55 | 56 | echo. 57 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 58 | echo. 59 | echo Please set the JAVA_HOME variable in your environment to match the 60 | echo location of your Java installation. 61 | 62 | goto fail 63 | 64 | :init 65 | @rem Get command-line arguments, handling Windows variants 66 | 67 | if not "%OS%" == "Windows_NT" goto win9xME_args 68 | 69 | :win9xME_args 70 | @rem Slurp the command line arguments. 71 | set CMD_LINE_ARGS= 72 | set _SKIP=2 73 | 74 | :win9xME_args_slurp 75 | if "x%~1" == "x" goto execute 76 | 77 | set CMD_LINE_ARGS=%* 78 | 79 | :execute 80 | @rem Setup the command line 81 | 82 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 83 | 84 | @rem Execute Gradle 85 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 86 | 87 | :end 88 | @rem End local scope for the variables with windows NT shell 89 | if "%ERRORLEVEL%"=="0" goto mainEnd 90 | 91 | :fail 92 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 93 | rem the _cmd.exe /c_ return code! 94 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 95 | exit /b 1 96 | 97 | :mainEnd 98 | if "%OS%"=="Windows_NT" endlocal 99 | 100 | :omega 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://github.com/saket/InboxRecyclerView/blob/master/docs/images/static_thumbnail.jpg)](https://www.youtube.com/playlist?list=PLY9Ajk3MUE7UAT4rn9LO-jSPfkPm5ewrQ) 2 | 3 | `InboxRecyclerView` is a library for building expandable descendant navigation inspired by [Google Inbox](http://androidniceties.tumblr.com/post/100872004063/inbox-by-gmail-google-play-link) and [Reply](https://material.io/design/material-studies/reply.html), and is an easy drop-in into existing projects. You can take a look at the [sample app](https://github.com/saket/InboxRecyclerView/tree/master/sample) for best practices or [download its APK](https://github.com/saket/InboxRecyclerView/releases) for trying it out on your phone. If you're interested in learning how it was created, [here's an in-depth blog post](https://saket.me/inbox-recyclerview). 4 | 5 | ```groovy 6 | implementation 'me.saket:inboxrecyclerview:3.0.0' 7 | ``` 8 | 9 | ### Usage 10 | 11 | **Layout** 12 | 13 | ```xml 14 | 17 | 18 | 22 | 23 | 28 | 29 | ``` 30 | 31 | **Expanding content** 32 | 33 | ```kotlin 34 | recyclerView.itemExpandAnimator = ItemExpandAnimator.scale() // or split() / none() 35 | recyclerView.dimPainter = DimPainter.listAndPage(Color.WHITE, alpha = 0.65f) 36 | 37 | recyclerView.expandablePage = findViewById(...).also { 38 | it.pushParentToolbarOnExpand(toolbar) 39 | it.addOnPullListener(PageCollapseEligibilityHapticFeedback(it)) 40 | } 41 | 42 | recyclerViewAdapter.onItemClick = { clickedItem -> 43 | // Load or update your content inside the page here. 44 | recyclerView.expandItem(clickedItem.adapterId) 45 | } 46 | ``` 47 | 48 | ### How do I… 49 | 50 | - [customize item expand animations?](docs/item_animators.md) 51 | - [control the pull-to-collapse gesture?](docs/pull_to_collapse.md) 52 | - [change background dimming?](docs/background_dim.md) 53 | - [listen to state changes?](docs/page_callbacks.md) 54 | - [expand items without using `Long` based adapter IDs?](https://github.com/saket/InboxRecyclerView/wiki/Custom-expansion-keys) 55 | 56 | ### Pull collapsible activities 57 | 58 | To maintain consistency across your whole app, a `PullCollapsibleActivity` is also included that brings the same animations and gesture to activities with little effort. 59 | 60 | Step 1. Extend `PullCollapsibleActivity`. 61 | 62 | Step 2. Add these attributes to the activity’s theme: 63 | 64 | ```xml 65 | true 66 | @null 67 | ``` 68 | 69 | ### License 70 | ``` 71 | Copyright 2018 Saket Narayan. 72 | 73 | Licensed under the Apache License, Version 2.0 (the "License"); 74 | you may not use this file except in compliance with the License. 75 | You may obtain a copy of the License at 76 | 77 | http://www.apache.org/licenses/LICENSE-2.0 78 | 79 | Unless required by applicable law or agreed to in writing, software 80 | distributed under the License is distributed on an "AS IS" BASIS, 81 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 82 | See the License for the specific language governing permissions and 83 | limitations under the License. 84 | ``` 85 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/BaseExpandablePageLayout.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.animation.ObjectAnimator 4 | import android.animation.TimeInterpolator 5 | import android.animation.ValueAnimator 6 | import android.content.Context 7 | import android.graphics.Outline 8 | import android.graphics.Rect 9 | import android.util.AttributeSet 10 | import android.view.View 11 | import android.view.ViewOutlineProvider 12 | import android.widget.FrameLayout 13 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator 14 | import me.saket.inboxrecyclerview.ANIMATION_START_DELAY 15 | 16 | /** 17 | * Animating change in dimensions by changing the actual width and height is expensive. 18 | * This layout animates change in dimensions by clipping visible bounds instead. 19 | */ 20 | abstract class BaseExpandablePageLayout @JvmOverloads constructor( 21 | context: Context, 22 | attrs: AttributeSet? = null 23 | ) : FrameLayout(context, attrs) { 24 | 25 | /** The visible portion of this layout. Warning: this is mutable. Use wisely! */ 26 | internal val clippedDimens: Rect = Rect() 27 | 28 | private var dimensionAnimator: ValueAnimator = ObjectAnimator() 29 | private var isFullyVisible: Boolean = false 30 | 31 | var animationDurationMillis = DEFAULT_ANIM_DURATION 32 | var animationInterpolator: TimeInterpolator = DEFAULT_ANIM_INTERPOLATOR 33 | 34 | init { 35 | clipBounds = clippedDimens 36 | 37 | outlineProvider = object : ViewOutlineProvider() { 38 | override fun getOutline(view: View, outline: Outline) { 39 | outline.setRect(0, 0, clippedDimens.width(), clippedDimens.height()) 40 | outline.alpha = clippedDimens.height().toFloat() / height 41 | } 42 | } 43 | } 44 | 45 | override fun onDetachedFromWindow() { 46 | dimensionAnimator.cancel() 47 | super.onDetachedFromWindow() 48 | } 49 | 50 | protected fun stopDimensionAnimation() { 51 | dimensionAnimator.cancel() 52 | } 53 | 54 | @Suppress("NAME_SHADOWING") 55 | fun animateDimensions(toWidth: Int, toHeight: Int) { 56 | stopDimensionAnimation() 57 | 58 | val isResettingClipping = toWidth == width && toHeight == height 59 | 60 | dimensionAnimator = ObjectAnimator.ofFloat(0F, 1F).apply { 61 | duration = animationDurationMillis 62 | interpolator = animationInterpolator 63 | startDelay = ANIMATION_START_DELAY 64 | 65 | val fromWidth = clippedDimens.width() 66 | val fromHeight = clippedDimens.height() 67 | 68 | addUpdateListener { 69 | val toWidth = if (isResettingClipping) width else toWidth 70 | val toHeight = if (isResettingClipping) height else toHeight 71 | 72 | val scale = it.animatedValue as Float 73 | val newWidth = ((toWidth - fromWidth) * scale + fromWidth).toInt() 74 | val newHeight = ((toHeight - fromHeight) * scale + fromHeight).toInt() 75 | setClippedDimensions(newWidth, newHeight) 76 | } 77 | } 78 | dimensionAnimator.start() 79 | } 80 | 81 | fun setClippedDimensions(newClippedWidth: Int, newClippedHeight: Int) { 82 | isFullyVisible = newClippedWidth > 0 && newClippedHeight > 0 83 | && newClippedWidth == width 84 | && newClippedHeight == height 85 | clippedDimens.set(0, 0, newClippedWidth, newClippedHeight) 86 | clipBounds = clippedDimens 87 | invalidateOutline() 88 | } 89 | 90 | /** Immediately reset the clipping so that this layout is fully visible. */ 91 | fun resetClipping() { 92 | setClippedDimensions(width, height) 93 | } 94 | 95 | companion object { 96 | private const val DEFAULT_ANIM_DURATION = 300L 97 | private val DEFAULT_ANIM_INTERPOLATOR = FastOutSlowInInterpolator() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/StandaloneExpandablePageLayout.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.content.Context 4 | import android.graphics.Rect 5 | import android.util.AttributeSet 6 | import me.saket.inboxrecyclerview.InboxRecyclerView 7 | import me.saket.inboxrecyclerview.InboxRecyclerView.ExpandedItemLocation 8 | import me.saket.inboxrecyclerview.PullCollapsibleActivity 9 | 10 | /** 11 | * An expandable page that can live without an accompanying [InboxRecyclerView]. 12 | * Can be used for making pull-collapsible screens where using [PullCollapsibleActivity] 13 | * isn't an option. 14 | * 15 | * Usage: 16 | * 17 | * ``` 18 | * val pageLayout = findViewById(...) 19 | * pageLayout.expandImmediately() 20 | * pageLayout.onPageRelease = { collapseEligible -> 21 | * if (collapseEligible) { 22 | * exitWithAnimation() 23 | * } 24 | * } 25 | * ``` 26 | * 27 | * where `exitWithAnimation()` can be used for playing your own exit 28 | * animation, or for playing the page collapse animation. 29 | * 30 | * ``` 31 | * pageLayout.addStateChangeCallbacks(object : SimplePageStateChangeCallbacks() { 32 | * override fun onPageCollapsed() { 33 | * exit() 34 | * } 35 | * }) 36 | * pageLayout.collapseTo(...) 37 | * ``` 38 | */ 39 | open class StandaloneExpandablePageLayout( 40 | context: Context, 41 | attrs: AttributeSet? = null 42 | ) : ExpandablePageLayout(context, attrs) { 43 | 44 | /** 45 | * Called when the page was pulled and released. 46 | * 47 | * @param collapseEligible Whether the page was pulled enough for collapsing it. 48 | */ 49 | lateinit var onPageRelease: (collapseEligible: Boolean) -> Unit 50 | 51 | init { 52 | contentOpacityWhenCollapsed = 1F 53 | } 54 | 55 | override fun dispatchOnPageReleaseCallback(collapseEligible: Boolean) { 56 | // Do not let a parent InboxRecyclerView (if present) collapse this page. 57 | onPageRelease(collapseEligible) 58 | } 59 | 60 | override fun onAttachedToWindow() { 61 | super.onAttachedToWindow() 62 | 63 | if (::onPageRelease.isInitialized.not()) { 64 | throw AssertionError("Did you forget to set onPageRelease?") 65 | } 66 | } 67 | 68 | override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { 69 | super.onLayout(changed, l, t, r, b) 70 | 71 | if (isInEditMode) { 72 | expandImmediately() 73 | setClippedDimensions(r, b) 74 | } 75 | } 76 | 77 | /** 78 | * Expand this page immediately. 79 | */ 80 | public override fun expandImmediately() { 81 | super.expandImmediately() 82 | } 83 | 84 | fun expandFromTop() { 85 | if (isLaidOut.not()) { 86 | post { expandFromTop() } 87 | return 88 | } 89 | 90 | expand( 91 | ExpandedItemLocation( 92 | viewIndex = -1, 93 | locationOnScreen = Rect(left, top, right, top) 94 | ) 95 | ) 96 | } 97 | 98 | fun collapseToTop() { 99 | collapse( 100 | ExpandedItemLocation( 101 | viewIndex = -1, 102 | locationOnScreen = Rect(left, top, right, top) 103 | ) 104 | ) 105 | } 106 | 107 | /** 108 | * Expand this page with animation with `fromShapeRect` as its initial dimensions. 109 | */ 110 | fun expandFrom(fromShapeRect: Rect) { 111 | if (isLaidOut.not()) { 112 | post { expandFrom(fromShapeRect) } 113 | return 114 | } 115 | 116 | expand(ExpandedItemLocation(viewIndex = -1, locationOnScreen = fromShapeRect)) 117 | } 118 | 119 | /** 120 | * @param toShapeRect Final dimensions of this page, when it fully collapses. 121 | */ 122 | fun collapseTo(toShapeRect: Rect) { 123 | collapse(ExpandedItemLocation(viewIndex = -1, locationOnScreen = toShapeRect)) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_about.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 20 | 21 | 31 | 32 | 41 | 42 | 43 | 49 | 50 | 58 | 59 | 67 | 68 | 76 | 77 | 85 | 86 | 92 | 93 | 94 | 95 | 106 | 107 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/fragment_email_thread.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 21 | 22 | 33 | 34 | 45 | 46 | 58 | 59 | 70 | 71 | 82 | 83 | 95 | 96 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/inbox/InboxActivity.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.inbox 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.graphics.Color 6 | import android.graphics.drawable.AnimatedVectorDrawable 7 | import android.os.Bundle 8 | import android.util.TypedValue 9 | import android.util.TypedValue.COMPLEX_UNIT_DIP 10 | import android.view.View 11 | import androidx.appcompat.app.AppCompatActivity 12 | import androidx.appcompat.content.res.AppCompatResources.getDrawable 13 | import androidx.recyclerview.widget.LinearLayoutManager 14 | import com.google.android.material.floatingactionbutton.FloatingActionButton 15 | import com.jakewharton.rxrelay2.PublishRelay 16 | import kotterknife.bindView 17 | import me.saket.inboxrecyclerview.InboxRecyclerView 18 | import me.saket.inboxrecyclerview.animation.ItemExpandAnimator 19 | import me.saket.inboxrecyclerview.dimming.DimPainter 20 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 21 | import me.saket.inboxrecyclerview.page.PageCollapseEligibilityHapticFeedback 22 | import me.saket.inboxrecyclerview.page.SimplePageStateChangeCallbacks 23 | import me.saket.inboxrecyclerview.sample.EmailRepository 24 | import me.saket.inboxrecyclerview.sample.R 25 | import me.saket.inboxrecyclerview.sample.about.AboutActivity 26 | import me.saket.inboxrecyclerview.sample.email.EmailThreadFragment 27 | 28 | class InboxActivity : AppCompatActivity() { 29 | 30 | private val recyclerView by bindView(R.id.inbox_recyclerview) 31 | private val emailPageLayout by bindView(R.id.inbox_email_thread_page) 32 | private val fab by bindView(R.id.inbox_fab) 33 | private val settingsButton by bindView(R.id.inbox_settings) 34 | 35 | private val onDestroy = PublishRelay.create() 36 | private val threadsAdapter = ThreadsAdapter() 37 | 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | setContentView(R.layout.activity_inbox) 41 | 42 | setupThreadList() 43 | setupThreadPage() 44 | setupFab() 45 | 46 | settingsButton.setOnClickListener { 47 | startActivity(AboutActivity.intent(this)) 48 | } 49 | } 50 | 51 | override fun onDestroy() { 52 | onDestroy.accept(Any()) 53 | super.onDestroy() 54 | } 55 | 56 | override fun onBackPressed() { 57 | if (emailPageLayout.isExpandedOrExpanding) { 58 | recyclerView.collapse() 59 | } else { 60 | super.onBackPressed() 61 | } 62 | } 63 | 64 | @SuppressLint("CheckResult") 65 | private fun setupThreadList() { 66 | recyclerView.layoutManager = LinearLayoutManager(this) 67 | recyclerView.expandablePage = emailPageLayout 68 | recyclerView.dimPainter = DimPainter.listAndPage( 69 | listColor = Color.WHITE, 70 | listAlpha = 0.75F, 71 | pageColor = Color.WHITE, 72 | pageAlpha = 0.65f 73 | ) 74 | recyclerView.itemExpandAnimator = ItemExpandAnimator.split() 75 | emailPageLayout.pullToCollapseThresholdDistance = dp(90) 76 | emailPageLayout.addOnPullListener(PageCollapseEligibilityHapticFeedback(emailPageLayout)) 77 | 78 | threadsAdapter.submitList(EmailRepository.threads()) 79 | recyclerView.adapter = threadsAdapter 80 | 81 | threadsAdapter.itemClicks 82 | .takeUntil(onDestroy) 83 | .subscribe { 84 | recyclerView.expandItem(it.itemId) 85 | } 86 | } 87 | 88 | @SuppressLint("CheckResult") 89 | private fun setupThreadPage() { 90 | var threadFragment = supportFragmentManager.findFragmentById(emailPageLayout.id) as EmailThreadFragment? 91 | if (threadFragment == null) { 92 | threadFragment = EmailThreadFragment() 93 | } 94 | 95 | supportFragmentManager 96 | .beginTransaction() 97 | .replace(emailPageLayout.id, threadFragment) 98 | .commitNowAllowingStateLoss() 99 | 100 | threadsAdapter.itemClicks 101 | .map { it.thread.id } 102 | .takeUntil(onDestroy) 103 | .subscribe { 104 | threadFragment.populate(it) 105 | } 106 | } 107 | 108 | private fun setupFab() { 109 | val avd = { iconRes: Int -> getDrawable(this, iconRes) as AnimatedVectorDrawable } 110 | fab.setImageDrawable(avd(R.drawable.avd_edit_to_reply_all)) 111 | 112 | emailPageLayout.addStateChangeCallbacks(object : SimplePageStateChangeCallbacks() { 113 | override fun onPageAboutToExpand(expandAnimDuration: Long) { 114 | val icon = avd(R.drawable.avd_edit_to_reply_all) 115 | fab.setImageDrawable(icon) 116 | icon.start() 117 | } 118 | 119 | override fun onPageAboutToCollapse(collapseAnimDuration: Long) { 120 | val icon = avd(R.drawable.avd_reply_all_to_edit) 121 | fab.setImageDrawable(icon) 122 | icon.start() 123 | } 124 | }) 125 | } 126 | } 127 | 128 | private fun Context.dp(value: Int): Int { 129 | val metrics = resources.displayMetrics 130 | return TypedValue.applyDimension(COMPLEX_UNIT_DIP, value.toFloat(), metrics).toInt() 131 | } -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/EmailRepository.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample 2 | 3 | object EmailRepository { 4 | 5 | fun threads(): List { 6 | val user = Person("me") 7 | 8 | return listOf( 9 | EmailThread( 10 | id = 0L, 11 | sender = Person("Google Express", R.drawable.avatar_googleexpress), 12 | subject = "Package shipped!", 13 | emails = listOf( 14 | Email( 15 | excerpt = "Cucumber Mask Facial has shipped", 16 | body = "Cucumber Mask Facial has shipped. It is expected to arrive by 28th October.", 17 | recipients = listOf(user), 18 | attachments = listOf(Attachment.ShippingUpdate), 19 | timestamp = "15 mins ago")) 20 | ), 21 | 22 | EmailThread( 23 | id = 1L, 24 | sender = Person("Ali Connors", R.drawable.avatar_ali_connors), 25 | subject = "Brunch this weekend?", 26 | emails = listOf( 27 | Email( 28 | excerpt = "I'll be in your neighburhood doing errands", 29 | body = "I'll be in your neighburhood doing errands. Would you want to catch up?", 30 | recipients = listOf(user), 31 | attachments = listOf(Attachment.CalendarEvent), 32 | timestamp = "25 mins ago")) 33 | ), 34 | 35 | EmailThread( 36 | id = 2L, 37 | sender = Person("Sandra Adams", R.drawable.avatar_sandra), 38 | subject = "Bonjour from Paris", 39 | emails = listOf( 40 | Email( 41 | body = "Here are some great shots from my trip to Paris this summer!", 42 | attachments = listOf( 43 | Attachment.Image(R.drawable.attachment_1), 44 | Attachment.Image(R.drawable.attachment_2), 45 | Attachment.Image(R.drawable.attachment_3), 46 | Attachment.Image(R.drawable.attachment_4)), 47 | recipients = listOf(user), 48 | timestamp = "6 hrs ago")) 49 | ), 50 | 51 | EmailThread( 52 | id = 3L, 53 | sender = Person("Trevor Hansen", R.drawable.avatar_trevor), 54 | subject = "High school reunion?", 55 | emails = listOf( 56 | Email( 57 | body = 58 | "Hi friends," 59 | + "\n\n" 60 | + "I was at the grocery store on Sunday " 61 | + "night... when I ran into Genie Williams! " 62 | + "I almost didn't recognize her after 20 years!" 63 | + "\n\n" 64 | + "Anyway, it turns out she is on " 65 | + "the organizing committee for the high school " 66 | + "reuinion this fall. I don't know if you were " 67 | + "planning on going or not, but she could " 68 | + "definitely use our help in trying to track " 69 | + "down lots of missing alums. If you can make " 70 | + "it, we're doing a llittle phone-tree party " 71 | + "at her place next Saturday, hoping that if " 72 | + "we can find one person, a few more will " 73 | + "emerge. What do you say?" 74 | + "\n\n" 75 | + "Talk soon!" 76 | + "\n" 77 | + "Trevor" 78 | , 79 | showBodyInThreads = false, 80 | recipients = listOf( 81 | user, 82 | Person("Rachel"), 83 | Person("Zach")), 84 | timestamp = "12 hrs ago")) 85 | ), 86 | 87 | EmailThread( 88 | id = 4L, 89 | sender = Person("Jerry Chang", R.drawable.avatar_jerry_chang), 90 | subject = "Visiting Town Next Thursday", 91 | emails = listOf( 92 | Email( 93 | body = "Hey there, I wanted to let you know I'll be visiting Wakanda on 31st August. Can't wait to try out your shiny new road bike.", 94 | recipients = listOf(user), 95 | timestamp = "16 hrs ago")) 96 | ), 97 | 98 | EmailThread( 99 | id = 5L, 100 | sender = Person("Mom", R.drawable.avatar_mom), 101 | subject = "Fwd: Article on Workplace Zen", 102 | emails = listOf( 103 | Email( 104 | body = "Hi sweetie, I saw this and thought you would find this useful.", 105 | recipients = listOf(user), 106 | attachments = listOf(Attachment.Pdf), 107 | timestamp = "Yesterday")) 108 | ) 109 | ) 110 | } 111 | 112 | fun thread(id: EmailThreadId): EmailThread { 113 | return threads().first { it.id == id } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/expander/InboxItemExpander.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.expander 2 | 3 | import android.graphics.Rect 4 | import android.os.Parcelable 5 | import androidx.core.view.children 6 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 7 | import kotlinx.android.parcel.Parcelize 8 | import me.saket.inboxrecyclerview.InboxRecyclerView 9 | import me.saket.inboxrecyclerview.InboxRecyclerView.ExpandedItemLocation 10 | import me.saket.inboxrecyclerview.locationOnScreen 11 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 12 | 13 | /** 14 | * Convenience function for treating [InboxItemExpander] like a fun interface. 15 | */ 16 | @Suppress("FunctionName") 17 | fun InboxItemExpander(identifier: ExpandingViewIdentifier): InboxItemExpander { 18 | return object : InboxItemExpander() { 19 | override fun identifyExpandingView(expandingItem: T, childViewHolders: Sequence) = 20 | identifier.identifyExpandingView(expandingItem, childViewHolders) 21 | } 22 | } 23 | 24 | /** 25 | * Identifies expanding list items so that [InboxRecyclerView] can animate between the item 26 | * and its [ExpandablePageLayout]. 27 | * 28 | * The default implementation is [AdapterIdBasedItemExpander] that uses adapter IDs, but apps 29 | * can implement their own expanders if using adapter IDs isn't desired because it's not 2020 30 | * anymore. 31 | * 32 | * Example usage: 33 | * 34 | * ``` 35 | * val itemExpander = InboxItemExpander { expandingItem, viewHolders -> 36 | * // Identify the expanding item's ViewHolder. 37 | * viewHolders.firstOrNull { it.{some identifier} == expandingItem } 38 | * } 39 | * inboxRecyclerView.itemExpander = itemExpander 40 | * itemExpander.expandItem(...) 41 | * ``` 42 | */ 43 | abstract class InboxItemExpander : ExpandingViewIdentifier { 44 | lateinit var recyclerView: InboxRecyclerView 45 | private var expandedItem: T? = null 46 | 47 | /** 48 | * Expand a list item. The item's View will be captured using [identifyExpandingView]. 49 | */ 50 | fun expandItem(item: T?, immediate: Boolean = false) { 51 | setItem(item) 52 | recyclerView.expandOnceLaidOut(immediate = immediate) 53 | } 54 | 55 | /** 56 | * Expand the page from the top. 57 | */ 58 | fun expandFromTop(immediate: Boolean = false) { 59 | expandItem(item = null, immediate = immediate) 60 | } 61 | 62 | fun collapse() { 63 | recyclerView.collapse() 64 | } 65 | 66 | /** 67 | * Update the currently expanded item. It's preferred that the item is updated through 68 | * [expandItem], but this may be used if the expanded item needs to be force-updated 69 | * while the page is already expanded. 70 | */ 71 | fun setItem(item: T?) { 72 | expandedItem = item 73 | } 74 | 75 | internal fun saveState(outState: Parcelable?): Parcelable { 76 | return ExpandedItemSavedState(outState, expandedItem) 77 | } 78 | 79 | @Suppress("UNCHECKED_CAST") 80 | internal fun restoreState(inState: Parcelable): Parcelable? { 81 | val savedState = inState as ExpandedItemSavedState 82 | setItem(savedState.expandedItem) 83 | return savedState.superState 84 | } 85 | 86 | internal fun captureExpandedItemInfo(): ExpandedItemLocation { 87 | val itemView = expandedItem?.let { expandedItem -> 88 | identifyExpandingView( 89 | expandingItem = expandedItem, 90 | childViewHolders = recyclerView.children.map(recyclerView::getChildViewHolder) 91 | )?.itemView 92 | } 93 | 94 | return if (itemView != null) { 95 | ExpandedItemLocation( 96 | viewIndex = recyclerView.indexOfChild(itemView), 97 | // Ignore translations done by the item expand animator. 98 | locationOnScreen = itemView.locationOnScreen(ignoreTranslations = true) 99 | ) 100 | 101 | } else { 102 | val locationOnScreen = recyclerView.locationOnScreen() 103 | val paddedY = locationOnScreen.top + recyclerView.paddingTop // This is where list items will be laid out from. 104 | ExpandedItemLocation( 105 | viewIndex = -1, 106 | locationOnScreen = Rect(locationOnScreen.left, paddedY, locationOnScreen.right, paddedY) 107 | ) 108 | } 109 | } 110 | } 111 | 112 | fun interface ExpandingViewIdentifier { 113 | /** 114 | * Called when [InboxItemExpander.expandItem] is called and [InboxRecyclerView] needs to find 115 | * the item's corresponding View. The View is only used for capturing its location on screen. 116 | * This may be called multiple times while the page is visible if [InboxRecyclerView] detects 117 | * that the list item may have moved. 118 | * 119 | * @param expandingItem Item passed to [InboxItemExpander.expandItem]. 120 | * @param childViewHolders ViewHolders for [InboxRecyclerView] visible list items. 121 | * 122 | * @return When null, the [ExpandablePageLayout] will be expanded from the top of the list. 123 | */ 124 | fun identifyExpandingView(expandingItem: T, childViewHolders: Sequence): ViewHolder? 125 | } 126 | 127 | @Parcelize 128 | internal data class ExpandedItemSavedState( 129 | val superState: Parcelable?, 130 | val expandedItem: T? 131 | ) : Parcelable 132 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/animation/ItemExpandAnimator.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.animation 2 | 3 | import android.graphics.Canvas 4 | import android.view.View 5 | import android.view.View.GONE 6 | import android.view.View.VISIBLE 7 | import androidx.core.view.doOnDetach 8 | import me.saket.inboxrecyclerview.InboxRecyclerView 9 | import me.saket.inboxrecyclerview.R 10 | import me.saket.inboxrecyclerview.animation.ItemExpandAnimator.Companion.none 11 | import me.saket.inboxrecyclerview.animation.ItemExpandAnimator.Companion.scale 12 | import me.saket.inboxrecyclerview.animation.ItemExpandAnimator.Companion.split 13 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 14 | 15 | /** 16 | * Controls how [InboxRecyclerView] items are animated when [ExpandablePageLayout] is moving. 17 | * See [split], [scale] and [none]. 18 | */ 19 | abstract class ItemExpandAnimator { 20 | private lateinit var onDetach: () -> Unit 21 | private var anchorViewOverlay: View? = null 22 | 23 | fun onAttachRecyclerView( 24 | recyclerView: InboxRecyclerView, 25 | page: ExpandablePageLayout 26 | ) { 27 | val changeDetector = PageLocationChangeDetector(page) { 28 | val anchorView = maybeUpdateAnchorOverlay(recyclerView, page) 29 | onPageMove(recyclerView, page, anchorView) 30 | } 31 | 32 | changeDetector.start() 33 | onDetach = { 34 | anchorViewOverlay?.let { page.overlay.remove(it) } 35 | resetAnimation(recyclerView, anchorViewOverlay = null) 36 | changeDetector.stop() 37 | } 38 | } 39 | 40 | fun onDetachRecyclerView() { 41 | onDetach() 42 | } 43 | 44 | private fun maybeUpdateAnchorOverlay( 45 | recyclerView: InboxRecyclerView, 46 | page: ExpandablePageLayout 47 | ): View? { 48 | val expandedItem = recyclerView.expandedItemLoc 49 | val expandedItemIndex = expandedItem.viewIndex 50 | 51 | // If the expanded item changed because, say, the window was resized, we want to recreate the overlay. 52 | val expandedItemChanged = anchorViewOverlay?.getTag(R.id.irv_expanded_item_info) != recyclerView.expandedItemLoc 53 | 54 | if (expandedItemChanged && expandedItem.isNotEmpty()) { 55 | recyclerView.getChildAt(expandedItemIndex)?.let { anchorView -> 56 | anchorViewOverlay = anchorView.captureImage(forOverlayOf = page).also { 57 | // Revert the layout position because 58 | // - ScaleExpandAnimator may have modified the RV's scale. 59 | // - SplitExpandAnimator may have modified the y-translation. 60 | it.layout(0, 0, anchorView.width, anchorView.height) 61 | 62 | it.setTag(R.id.irv_expanded_item_info, expandedItem) 63 | page.overlay.add(it) 64 | 65 | anchorView.visibility = GONE 66 | it.doOnDetach { // Note to self: this must be called after this overlay is added. 67 | anchorView.visibility = VISIBLE 68 | anchorViewOverlay = null 69 | } 70 | } 71 | } 72 | } 73 | 74 | if (expandedItem.isEmpty() && anchorViewOverlay != null) { 75 | page.overlay.remove(anchorViewOverlay!!) 76 | } 77 | 78 | return anchorViewOverlay 79 | } 80 | 81 | /** 82 | * Called when the page changes its position and/or dimensions. This can 83 | * happen when the page is expanding, collapsing or being pulled vertically. 84 | * 85 | * Override this to animate [InboxRecyclerView] items with the page's movement. 86 | * 87 | * @param anchorViewOverlay An overlay of the item View that is expanding/collapsing. 88 | * It'll be null if the page is collapsed or if the page was expanded using 89 | * [InboxRecyclerView.expandFromTop]. 90 | */ 91 | abstract fun onPageMove( 92 | recyclerView: InboxRecyclerView, 93 | page: ExpandablePageLayout, 94 | anchorViewOverlay: View? 95 | ) 96 | 97 | /** 98 | * Called when a page is detached from its [InboxRecyclerView]. 99 | */ 100 | abstract fun resetAnimation( 101 | recyclerView: InboxRecyclerView, 102 | anchorViewOverlay: View? 103 | ) 104 | 105 | /** Called before the list items are drawn on the canvas. */ 106 | open fun transformRecyclerViewCanvas( 107 | recyclerView: InboxRecyclerView, 108 | canvas: Canvas, 109 | block: Canvas.() -> Unit 110 | ) { 111 | block(canvas) 112 | } 113 | 114 | /** 115 | * 0.0 -> fully collapsed. 116 | * 1.0 -> fully expanded. 117 | */ 118 | protected fun ExpandablePageLayout.expandRatio(rv: InboxRecyclerView): Float { 119 | val anchorHeight = rv.expandedItemLoc.locationOnScreen.height() 120 | val pageHeight = clippedDimens.height() 121 | return ((anchorHeight - pageHeight) / (anchorHeight - height).toFloat()).coerceIn(0.0f, 1.0f) 122 | } 123 | 124 | companion object { 125 | /** 126 | * [https://github.com/saket/InboxRecyclerView/tree/master/images/animators/animator_split.mp4] 127 | */ 128 | @JvmStatic 129 | fun split(): ItemExpandAnimator = SplitExpandAnimator() 130 | 131 | /** 132 | * [https://github.com/saket/InboxRecyclerView/tree/master/images/animators/animator_scale.mp4] 133 | */ 134 | @JvmStatic 135 | fun scale(): ItemExpandAnimator = ScaleExpandAnimator() 136 | 137 | /** 138 | * [https://github.com/saket/InboxRecyclerView/tree/master/images/animators/animator_none.mp4] 139 | */ 140 | @JvmStatic 141 | fun none(): ItemExpandAnimator = NoneAnimator() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/page/PullToCollapseNestedScroller.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.page 2 | 3 | import android.graphics.PointF 4 | import android.view.MotionEvent 5 | import android.view.View 6 | import androidx.core.view.ViewCompat.TYPE_NON_TOUCH 7 | import androidx.core.view.ViewCompat.TYPE_TOUCH 8 | import me.saket.inboxrecyclerview.Views 9 | import me.saket.inboxrecyclerview.page.InterceptResult.INTERCEPTED 10 | import java.util.ArrayList 11 | import kotlin.math.abs 12 | 13 | internal class PullToCollapseNestedScroller(private val page: ExpandablePageLayout) { 14 | 15 | /** Minimum Y-distance the page has to be dragged before it's eligible for collapse. */ 16 | internal var collapseDistanceThreshold: Int = (Views.toolbarHeight(page.context)) 17 | private var elasticScrollingFactor = 3.5f 18 | 19 | internal val isNestedScrolling get() = interceptedUntilNextScroll == false 20 | 21 | private val onPullListeners = ArrayList(3) 22 | private var interceptedUntilNextScroll: Boolean? = null 23 | private val scrollStartedAt = PointF(0f, 0f) 24 | 25 | fun storeTouchEvent(ev: MotionEvent) { 26 | if (ev.action == MotionEvent.ACTION_DOWN) { 27 | scrollStartedAt.set(ev.x, ev.y) 28 | } 29 | } 30 | 31 | fun onStartNestedScroll(dy: Int, type: Int) { 32 | check(type == TYPE_TOUCH) 33 | check(interceptedUntilNextScroll == null) 34 | 35 | if (page.isExpanded) { 36 | page.stopAnyOngoingAnimation() 37 | } 38 | 39 | interceptedUntilNextScroll = INTERCEPTED == page.handleOnPullToCollapseIntercept( 40 | downX = scrollStartedAt.x, 41 | downY = scrollStartedAt.y, 42 | deltaUpwardSwipe = dy >= 0f // i.e., finger is moving from top to bottom. 43 | ) 44 | 45 | if (interceptedUntilNextScroll == false) { 46 | dispatchPullStartedCallback() 47 | } 48 | } 49 | 50 | /** 51 | * It is recommended to handle both onPreScroll() and onScroll(), but the 52 | * latter doesn't hide overscroll glow if a nested scroll was consumed. 53 | */ 54 | fun onNestedPreScroll(target: View?, dy: Int, consumed: IntArray, type: Int) { 55 | // Ignore flings. 56 | if (type == TYPE_NON_TOUCH) { 57 | // Avoid letting the content fling if the page is 58 | // collapsing, preventing overscroll glows to show up. 59 | consumed[1] = if (page.isCollapsing) dy else 0 60 | return 61 | } 62 | 63 | if (interceptedUntilNextScroll == null) { 64 | // Note to self: this must happen only for scrolls (not flings) otherwise 65 | // isNestedScrolling will become true when a fling is started. 66 | onStartNestedScroll(dy, type) 67 | } 68 | 69 | if (interceptedUntilNextScroll == true) { 70 | return 71 | } 72 | 73 | val deltaDraggingDown = dy > 0f 74 | val canPageScroll = when { 75 | deltaDraggingDown -> page.translationY > 0f 76 | else -> page.translationY < 0f 77 | } 78 | val canContentScroll = { 79 | target != null && target.canScrollVertically(if (deltaDraggingDown) +1 else -1) 80 | } 81 | 82 | if (canPageScroll || !canContentScroll()) { 83 | movePageBy(dy) 84 | consumed[1] = dy 85 | } 86 | } 87 | 88 | private fun movePageBy(dy: Int) { 89 | var elasticDy = dy / elasticScrollingFactor 90 | if (page.isCollapseEligible) { 91 | val extraElasticity = collapseDistanceThreshold / (2F * abs(page.translationY)) 92 | elasticDy *= extraElasticity 93 | } 94 | 95 | page.translationY += -elasticDy // dy is negative when the scroll is downwards. 96 | 97 | val draggingDown = page.translationY < 0f 98 | val deltaDraggingDown = dy < 0f 99 | dispatchPulledCallback(elasticDy, draggingDown, deltaDraggingDown) 100 | } 101 | 102 | fun onStopNestedScroll(type: Int) { 103 | if (type == TYPE_TOUCH) { 104 | if (isNestedScrolling && abs(page.translationY) > 0f) { 105 | // The page is responsible for animating back into position if the page 106 | // wasn't eligible for collapse. I no longer remember why I did this. 107 | dispatchReleaseCallback() 108 | } 109 | interceptedUntilNextScroll = null 110 | } 111 | } 112 | 113 | // === Pull listeners. === // 114 | 115 | fun addOnPullListener(listener: OnExpandablePagePullListener) { 116 | onPullListeners.add(listener) 117 | } 118 | 119 | fun removeOnPullListener(listener: OnExpandablePagePullListener) { 120 | onPullListeners.remove(listener) 121 | } 122 | 123 | fun removeAllOnPullListeners() { 124 | onPullListeners.clear() 125 | } 126 | 127 | private fun dispatchReleaseCallback() { 128 | for (listener in onPullListeners) { 129 | listener.onRelease(page.isCollapseEligible) 130 | } 131 | } 132 | 133 | private fun dispatchPullStartedCallback() { 134 | for (listener in onPullListeners) { 135 | listener.onPullStarted() 136 | } 137 | } 138 | 139 | private fun dispatchPulledCallback(deltaY: Float, draggingDown: Boolean, deltaDraggingDown: Boolean) { 140 | for (i in onPullListeners.indices) { // Avoids creating an iterator on each callback. 141 | val onPullListener = onPullListeners[i] 142 | onPullListener.onPull( 143 | deltaY = deltaY, 144 | currentTranslationY = page.translationY, 145 | upwardPull = draggingDown, 146 | deltaUpwardPull = deltaDraggingDown, 147 | collapseEligible = page.isCollapseEligible 148 | ) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/inbox/ThreadsAdapter.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.inbox 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.TypedValue 5 | import android.view.LayoutInflater 6 | import android.view.MotionEvent 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.view.ViewStub 10 | import android.widget.ImageView 11 | import android.widget.TextView 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL 14 | import androidx.recyclerview.widget.ListAdapter 15 | import androidx.recyclerview.widget.RecyclerView 16 | import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener 17 | import com.jakewharton.rxrelay2.PublishRelay 18 | import me.saket.inboxrecyclerview.sample.Attachment 19 | import me.saket.inboxrecyclerview.sample.EmailThread 20 | import me.saket.inboxrecyclerview.sample.R 21 | import me.saket.inboxrecyclerview.sample.exhaustive 22 | import me.saket.inboxrecyclerview.sample.inbox.ThreadsAdapter.ViewType.NORMAL 23 | import me.saket.inboxrecyclerview.sample.inbox.ThreadsAdapter.ViewType.WITH_IMAGE_ATTACHMENTS 24 | import me.saket.inboxrecyclerview.sample.inbox.ThreadsAdapter.ViewType.values 25 | import me.saket.inboxrecyclerview.sample.widgets.setDrawableStart 26 | 27 | class ThreadsAdapter : ListAdapter(EmailThread.ItemDiffer()) { 28 | 29 | enum class ViewType { 30 | NORMAL, 31 | WITH_IMAGE_ATTACHMENTS 32 | } 33 | 34 | val itemClicks = PublishRelay.create()!! 35 | 36 | init { 37 | setHasStableIds(true) 38 | } 39 | 40 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmailViewHolder { 41 | val threadLayout = LayoutInflater.from(parent.context).inflate(R.layout.list_email_thread, parent, false) 42 | return when (values()[viewType]) { 43 | NORMAL -> EmailViewHolder(threadLayout, itemClicks) 44 | WITH_IMAGE_ATTACHMENTS -> EmailViewHolderWithImageAttachments(threadLayout, itemClicks) 45 | } 46 | } 47 | 48 | override fun onBindViewHolder(holder: EmailViewHolder, position: Int) { 49 | holder.emailThread = getItem(position) 50 | holder.render() 51 | } 52 | 53 | override fun getItemId(position: Int): Long { 54 | return getItem(position).id 55 | } 56 | 57 | override fun getItemViewType(position: Int): Int { 58 | val latestEmail = getItem(position).emails.last() 59 | return when { 60 | latestEmail.hasImageAttachments -> WITH_IMAGE_ATTACHMENTS.ordinal 61 | else -> NORMAL.ordinal 62 | } 63 | } 64 | } 65 | 66 | open class EmailViewHolder( 67 | itemView: View, 68 | itemClicks: PublishRelay 69 | ) : RecyclerView.ViewHolder(itemView) { 70 | 71 | private val bylineTextView = itemView.findViewById(R.id.emailthread_item_byline) 72 | private val subjectTextView = itemView.findViewById(R.id.emailthread_item_subject) 73 | private val bodyTextView = itemView.findViewById(R.id.emailthread_item_body) 74 | private val avatarImageView = itemView.findViewById(R.id.emailthread_item_avatar) 75 | 76 | lateinit var emailThread: EmailThread 77 | 78 | init { 79 | itemView.setOnClickListener { 80 | itemClicks.accept(EmailThreadClicked(emailThread, itemId)) 81 | } 82 | } 83 | 84 | @SuppressLint("SetTextI18n") 85 | open fun render() { 86 | val latestEmail = emailThread.emails.last() 87 | bylineTextView.text = "${emailThread.sender.name} — ${latestEmail.timestamp}" 88 | 89 | subjectTextView.text = emailThread.subject 90 | val subjectTextSize = subjectTextView.resources.getDimensionPixelSize(when { 91 | latestEmail.hasImageAttachments -> R.dimen.emailthread_subject_textize_with_photo_attachments 92 | else -> R.dimen.emailthread_subject_textize_without_photo_attachments 93 | }) 94 | subjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, subjectTextSize.toFloat()) 95 | 96 | bodyTextView.apply { 97 | text = latestEmail.excerpt.replace("\n", " ") 98 | visibility = if (latestEmail.showBodyInThreads) View.VISIBLE else View.GONE 99 | 100 | val attachments = latestEmail.attachments 101 | val attachment = attachments.firstOrNull() 102 | when (attachment) { 103 | is Attachment.Image -> setDrawableStart(null) 104 | is Attachment.Pdf -> setDrawableStart(R.drawable.ic_attachment_24dp) 105 | is Attachment.ShippingUpdate -> setDrawableStart(R.drawable.ic_attachment_24dp) 106 | is Attachment.CalendarEvent, null -> setDrawableStart(null) 107 | }.exhaustive() 108 | } 109 | 110 | avatarImageView.setImageResource(emailThread.sender.profileImageRes!!) 111 | } 112 | } 113 | 114 | class EmailViewHolderWithImageAttachments( 115 | itemView: View, 116 | itemClicks: PublishRelay 117 | ) : EmailViewHolder(itemView, itemClicks) { 118 | private val adapter = ImageAttachmentAdapter(touchListener = itemView::onTouchEvent) 119 | 120 | init { 121 | val viewStub = itemView.findViewById(R.id.emailthread_image_attachments_stub) 122 | viewStub.inflate() 123 | 124 | itemView.findViewById(R.id.emailthread_image_attachments).let { 125 | it.layoutManager = LinearLayoutManager(itemView.context, HORIZONTAL, false) 126 | it.adapter = adapter 127 | } 128 | } 129 | 130 | override fun render() { 131 | super.render() 132 | val latestEmail = emailThread.emails.last() 133 | adapter.images = latestEmail.attachments.filterIsInstance(Attachment.Image::class.java) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /sample/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 | -------------------------------------------------------------------------------- /sample/src/main/java/me/saket/inboxrecyclerview/sample/email/EmailThreadFragment.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview.sample.email 2 | 3 | import android.annotation.SuppressLint 4 | import android.graphics.Rect 5 | import android.graphics.RectF 6 | import android.os.Bundle 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.widget.ImageButton 11 | import android.widget.ImageView 12 | import android.widget.TextView 13 | import androidx.fragment.app.Fragment 14 | import com.jakewharton.rxrelay2.BehaviorRelay 15 | import com.jakewharton.rxrelay2.PublishRelay 16 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 17 | import me.saket.inboxrecyclerview.page.InterceptResult 18 | import me.saket.inboxrecyclerview.page.SimplePageStateChangeCallbacks 19 | import me.saket.inboxrecyclerview.sample.Attachment.CalendarEvent 20 | import me.saket.inboxrecyclerview.sample.Attachment.Image 21 | import me.saket.inboxrecyclerview.sample.Attachment.Pdf 22 | import me.saket.inboxrecyclerview.sample.Attachment.ShippingUpdate 23 | import me.saket.inboxrecyclerview.sample.EmailRepository 24 | import me.saket.inboxrecyclerview.sample.EmailThread 25 | import me.saket.inboxrecyclerview.sample.EmailThreadId 26 | import me.saket.inboxrecyclerview.sample.R 27 | import me.saket.inboxrecyclerview.sample.exhaustive 28 | 29 | class EmailThreadFragment : Fragment() { 30 | 31 | private val emailThreadPage by lazy { view!!.parent as ExpandablePageLayout } 32 | private val scrollableContainer by lazy { view!!.findViewById(R.id.emailthread_scrollable_container) } 33 | private val subjectTextView by lazy { view!!.findViewById(R.id.emailthread_subject) } 34 | private val byline1TextView by lazy { view!!.findViewById(R.id.emailthread_byline1) } 35 | private val byline2TextView by lazy { view!!.findViewById(R.id.emailthread_byline2) } 36 | private val avatarImageView by lazy { view!!.findViewById(R.id.emailthread_avatar) } 37 | private val bodyTextView by lazy { view!!.findViewById(R.id.emailthread_body) } 38 | private val collapseButton by lazy { view!!.findViewById(R.id.emailthread_collapse) } 39 | private val attachmentContainer by lazy { view!!.findViewById(R.id.emailthread_attachment_container) } 40 | 41 | private val threadIds = BehaviorRelay.create() 42 | private val onDestroys = PublishRelay.create() 43 | 44 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? { 45 | return inflater.inflate(R.layout.fragment_email_thread, container, false) 46 | } 47 | 48 | @SuppressLint("CheckResult") 49 | override fun onViewCreated(view: View, savedState: Bundle?) { 50 | super.onViewCreated(view, savedState) 51 | 52 | if (savedState != null) { 53 | onRestoreInstanceState(savedState) 54 | } 55 | 56 | threadIds 57 | .map { EmailRepository.thread(id = it) } 58 | .takeUntil(onDestroys) 59 | .subscribe { render(it) } 60 | 61 | collapseButton.setOnClickListener { 62 | requireActivity().onBackPressed() 63 | } 64 | 65 | emailThreadPage.addStateChangeCallbacks(object : SimplePageStateChangeCallbacks() { 66 | override fun onPageCollapsed() { 67 | scrollableContainer.scrollTo(0, 0) 68 | } 69 | }) 70 | } 71 | 72 | override fun onDestroyView() { 73 | onDestroys.accept(Any()) 74 | super.onDestroyView() 75 | } 76 | 77 | override fun onSaveInstanceState(outState: Bundle) { 78 | if (threadIds.hasValue()) { 79 | outState.putLong("thread_id", threadIds.value) 80 | } 81 | super.onSaveInstanceState(outState) 82 | } 83 | 84 | private fun onRestoreInstanceState(savedState: Bundle) { 85 | val retainedThreadId: Long? = savedState.getLong("thread_id") 86 | if (retainedThreadId != null) { 87 | threadIds.accept(retainedThreadId) 88 | } 89 | } 90 | 91 | fun populate(threadId: EmailThreadId) { 92 | threadIds.accept(threadId) 93 | } 94 | 95 | @SuppressLint("SetTextI18n") 96 | private fun render(emailThread: EmailThread) { 97 | val latestEmail = emailThread.emails.last() 98 | 99 | subjectTextView.text = emailThread.subject 100 | byline1TextView.text = "${emailThread.sender.name} — ${latestEmail.timestamp}" 101 | 102 | val cmvRecipients = if (latestEmail.recipients.size > 1) { 103 | latestEmail.recipients 104 | .dropLast(1) 105 | .joinToString(transform = { it.name }) 106 | .plus(" and ${latestEmail.recipients.last().name}") 107 | } else { 108 | latestEmail.recipients[0].name 109 | } 110 | byline2TextView.text = "To $cmvRecipients" 111 | 112 | bodyTextView.text = latestEmail.body 113 | avatarImageView.setImageResource(emailThread.sender.profileImageRes!!) 114 | 115 | renderAttachments(emailThread) 116 | } 117 | 118 | private fun renderAttachments(thread: EmailThread) { 119 | attachmentContainer.removeAllViews() 120 | 121 | val attachments = thread.emails.last().attachments 122 | 123 | val attachment = attachments.firstOrNull() 124 | when (attachment) { 125 | is Image -> renderImageAttachments() 126 | is Pdf -> renderPdfAttachment(attachment) 127 | is CalendarEvent -> renderCalendarEvent(attachment) 128 | is ShippingUpdate -> renderShippingUpdate(attachment) 129 | null -> { 130 | } 131 | }.exhaustive() 132 | } 133 | 134 | private fun renderImageAttachments() { 135 | View.inflate(context, R.layout.include_email_image_gallery, attachmentContainer) 136 | } 137 | 138 | private fun renderPdfAttachment(attachment: Pdf) { 139 | View.inflate(context, R.layout.include_email_pdf_attachment, attachmentContainer) 140 | } 141 | 142 | private fun renderCalendarEvent(attachment: CalendarEvent) { 143 | View.inflate(context, R.layout.include_email_calendar_invite, attachmentContainer) 144 | } 145 | 146 | private fun renderShippingUpdate(attachment: ShippingUpdate) { 147 | View.inflate(context, R.layout.include_email_shipping_update, attachmentContainer) 148 | } 149 | } 150 | 151 | private fun View.globalVisibleRect(): RectF { 152 | val rect = Rect() 153 | getGlobalVisibleRect(rect) 154 | return RectF(rect.left.toFloat(), rect.top.toFloat(), rect.right.toFloat(), rect.bottom.toFloat()) 155 | } -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/PullCollapsibleActivity.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | import android.graphics.Color 4 | import android.graphics.Rect 5 | import android.graphics.drawable.ColorDrawable 6 | import android.graphics.drawable.Drawable 7 | import android.os.Bundle 8 | import android.util.TypedValue 9 | import android.view.MenuItem 10 | import android.view.View 11 | import android.view.ViewGroup 12 | import androidx.appcompat.app.AppCompatActivity 13 | import androidx.core.content.ContextCompat 14 | import me.saket.inboxrecyclerview.page.SimplePageStateChangeCallbacks 15 | import me.saket.inboxrecyclerview.page.StandaloneExpandablePageLayout 16 | 17 | /** 18 | * An Activity that can be dismissed by pulling it vertically. 19 | * Requires these these properties to be present in the Activity theme: 20 | * 21 | * true 22 | * @null 23 | */ 24 | @Suppress("MemberVisibilityCanBePrivate") 25 | abstract class PullCollapsibleActivity : AppCompatActivity() { 26 | protected lateinit var activityPageLayout: StandaloneExpandablePageLayout 27 | 28 | private var expandCalled = false 29 | private var expandedFromRect: Rect? = null 30 | private var standardToolbarHeight: Int = 0 31 | private var pullCollapsibleEnabled = true 32 | private var wasActivityRecreated: Boolean = false 33 | private var entryAnimationEnabled = true 34 | 35 | override fun onCreate(savedInstanceState: Bundle?) { 36 | super.onCreate(savedInstanceState) 37 | wasActivityRecreated = savedInstanceState == null 38 | 39 | if (entryAnimationEnabled && pullCollapsibleEnabled) { 40 | overridePendingTransition(0, 0) 41 | } 42 | 43 | standardToolbarHeight = Views.toolbarHeight(this) 44 | } 45 | 46 | override fun onStart() { 47 | super.onStart() 48 | 49 | if (expandCalled.not()) { 50 | throw AssertionError("Did you forget to call expandFromTop()/expandFrom()?") 51 | } 52 | } 53 | 54 | fun setEntryAnimationEnabled(entryAnimationEnabled: Boolean) { 55 | this.entryAnimationEnabled = entryAnimationEnabled 56 | } 57 | 58 | /** 59 | * Defaults to true. When disabled, this behaves like a normal Activity with no expandable page animations. 60 | * Should be called before onCreate(). 61 | */ 62 | protected fun setPullToCollapseEnabled(enabled: Boolean) { 63 | pullCollapsibleEnabled = enabled 64 | } 65 | 66 | override fun setContentView(layoutResID: Int) { 67 | val parent = findViewById(android.R.id.content) 68 | val view = layoutInflater.inflate(layoutResID, parent, false) 69 | setContentView(view) 70 | } 71 | 72 | override fun setContentView(view: View) { 73 | activityPageLayout = wrapInExpandablePage(view) 74 | super.setContentView(activityPageLayout) 75 | if (entryAnimationEnabled) { 76 | expandFromTop() 77 | } else { 78 | expandImmediately() 79 | } 80 | } 81 | 82 | override fun setContentView(view: View, params: ViewGroup.LayoutParams) { 83 | activityPageLayout = wrapInExpandablePage(view) 84 | super.setContentView(activityPageLayout, params) 85 | if (entryAnimationEnabled) { 86 | expandFromTop() 87 | } else { 88 | expandImmediately() 89 | } 90 | } 91 | 92 | private fun wrapInExpandablePage(view: View): StandaloneExpandablePageLayout { 93 | val pageLayout = StandaloneExpandablePageLayout(this) 94 | pageLayout.elevation = resources.getDimensionPixelSize(R.dimen.irv_pull_collapsible_activity_elevation).toFloat() 95 | pageLayout.background = windowBackgroundFromTheme() 96 | 97 | window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) 98 | 99 | if (pullCollapsibleEnabled) { 100 | pageLayout.pullToCollapseThresholdDistance = standardToolbarHeight 101 | pageLayout.onPageRelease = { collapseEligible -> 102 | if (collapseEligible) { 103 | finish() 104 | } 105 | } 106 | } else { 107 | pageLayout.pullToCollapseEnabled = false 108 | pageLayout.expandImmediately() 109 | } 110 | 111 | pageLayout.addView(view) 112 | return pageLayout 113 | } 114 | 115 | protected fun expandFromTop() { 116 | expandCalled = true 117 | activityPageLayout.doOnLayout2 { 118 | val pageLocation = activityPageLayout.locationOnScreen() 119 | val toolbarRect = Rect(pageLocation.left, standardToolbarHeight, pageLocation.right, standardToolbarHeight) 120 | expandFrom(toolbarRect) 121 | } 122 | } 123 | 124 | protected fun expandImmediately() { 125 | expandCalled = true 126 | activityPageLayout.expandImmediately() 127 | } 128 | 129 | protected fun expandFrom(fromRect: Rect) { 130 | expandCalled = true 131 | 132 | expandedFromRect = fromRect 133 | activityPageLayout.doOnLayout2 { 134 | if (wasActivityRecreated) { 135 | activityPageLayout.expandFrom(fromRect) 136 | } else { 137 | activityPageLayout.expandImmediately() 138 | } 139 | } 140 | } 141 | 142 | override fun finish() { 143 | // Note to self: It's important to check if expandedFromRect != null 144 | // and not expandCalled, because expandCalled gets set after the page 145 | // layout gets measured. 146 | 147 | if (pullCollapsibleEnabled && expandedFromRect != null) { 148 | activityPageLayout.addStateChangeCallbacks(object : SimplePageStateChangeCallbacks() { 149 | override fun onPageCollapsed() { 150 | super@PullCollapsibleActivity.finish() 151 | overridePendingTransition(0, 0) 152 | } 153 | }) 154 | activityPageLayout.collapseTo(expandedFromRect!!) 155 | 156 | } else { 157 | super.finish() 158 | } 159 | } 160 | 161 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 162 | return if (item.itemId == android.R.id.home) { 163 | finish() 164 | true 165 | 166 | } else { 167 | super.onOptionsItemSelected(item) 168 | } 169 | } 170 | 171 | private fun windowBackgroundFromTheme(): Drawable { 172 | val attributes = TypedValue() 173 | theme.resolveAttribute(android.R.attr.windowBackground, attributes, true) 174 | val isColorInt = attributes.type >= TypedValue.TYPE_FIRST_COLOR_INT && attributes.type <= TypedValue.TYPE_LAST_COLOR_INT 175 | 176 | return when { 177 | isColorInt -> ColorDrawable(attributes.data) 178 | else -> ContextCompat.getDrawable(this, attributes.resourceId)!! 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin, switch paths to Windows format before running java 129 | if $cygwin ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=$((i+1)) 158 | done 159 | case $i in 160 | (0) set -- ;; 161 | (1) set -- "$args0" ;; 162 | (2) set -- "$args0" "$args1" ;; 163 | (3) set -- "$args0" "$args1" "$args2" ;; 164 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=$(save "$@") 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 184 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 185 | cd "$(dirname "$0")" 186 | fi 187 | 188 | exec "$JAVACMD" "$@" 189 | -------------------------------------------------------------------------------- /gradle/gradle-mvn-push.gradle: -------------------------------------------------------------------------------- 1 | /* 2 | * Taken from AutoDispose (https://github.com/uber/AutoDispose) and 3 | * modified to remove RxJava javadoc. 4 | */ 5 | 6 | apply plugin: 'maven' 7 | apply plugin: 'signing' 8 | 9 | version = VERSION_NAME 10 | group = GROUP 11 | 12 | def isReleaseBuild() { 13 | return VERSION_NAME.contains("SNAPSHOT") == false 14 | } 15 | 16 | def getReleaseRepositoryUrl() { 17 | return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL : 18 | "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 19 | } 20 | 21 | def getSnapshotRepositoryUrl() { 22 | return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL : 23 | "https://oss.sonatype.org/content/repositories/snapshots/" 24 | } 25 | 26 | def getRepositoryUsername() { 27 | return hasProperty('SONATYPE_NEXUS_USERNAME') ? SONATYPE_NEXUS_USERNAME : "" 28 | } 29 | 30 | def getRepositoryPassword() { 31 | return hasProperty('SONATYPE_NEXUS_PASSWORD') ? SONATYPE_NEXUS_PASSWORD : "" 32 | } 33 | 34 | def kotlinAndroidArtifactTasks() { 35 | if (!project.plugins.hasPlugin('org.jetbrains.dokka-android')) { 36 | throw new GradleException("Apply the dokka-android plugin in ${project.name}") 37 | } 38 | 39 | dokka { 40 | outputFormat = 'html' 41 | outputDirectory = "$buildDir/docs/kdoc" 42 | classpath = new ArrayList(project.tasks['assemble'].outputs.files.files) 43 | sourceDirs = android.sourceSets.main.java.srcDirs 44 | } 45 | 46 | task docJar(type: Jar, dependsOn: dokka) { 47 | classifier = 'javadoc' 48 | from dokka.outputDirectory 49 | } 50 | 51 | task sourceJar(type: Jar) { 52 | classifier = 'sources' 53 | from android.sourceSets.main.java.srcDirs 54 | } 55 | } 56 | 57 | def androidArtifactTasks() { 58 | task androidJavadoc(type: Javadoc) { 59 | source = android.sourceSets.main.java.srcDirs 60 | 61 | classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) 62 | exclude '**/internal/*' 63 | 64 | if (JavaVersion.current().isJava8Compatible()) { 65 | options.addStringOption('Xdoclint:none', '-quiet') 66 | } 67 | } 68 | 69 | task docJar(type: Jar, dependsOn: androidJavadoc) { 70 | classifier = 'javadoc' 71 | from androidJavadoc.destinationDir 72 | } 73 | 74 | task sourceJar(type: Jar) { 75 | classifier = 'sources' 76 | from android.sourceSets.main.java.sourceFiles 77 | } 78 | } 79 | 80 | def javaArtifactTasks() { 81 | task javaJavadoc(type: Javadoc) { 82 | source = sourceSets.main.allSource 83 | 84 | if (JavaVersion.current().isJava8Compatible()) { 85 | options.addStringOption('Xdoclint:none', '-quiet') 86 | } 87 | } 88 | 89 | task docJar(type: Jar, dependsOn: javaJavadoc) { 90 | classifier = 'javadoc' 91 | from javaJavadoc.destinationDir 92 | } 93 | 94 | task sourceJar(type: Jar, dependsOn: classes) { 95 | classifier = 'sources' 96 | from sourceSets.main.allSource.srcDirs 97 | } 98 | } 99 | 100 | afterEvaluate { project -> 101 | uploadArchives { 102 | repositories { 103 | mavenDeployer { 104 | beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } 105 | 106 | pom.groupId = GROUP 107 | pom.artifactId = POM_ARTIFACT_ID 108 | pom.version = VERSION_NAME 109 | 110 | repository(url: getReleaseRepositoryUrl()) { 111 | authentication(userName: getRepositoryUsername(), password: getRepositoryPassword()) 112 | } 113 | snapshotRepository(url: getSnapshotRepositoryUrl()) { 114 | authentication(userName: getRepositoryUsername(), password: getRepositoryPassword()) 115 | } 116 | 117 | pom.project { 118 | name POM_NAME 119 | packaging POM_PACKAGING 120 | description POM_DESCRIPTION 121 | url POM_URL 122 | 123 | scm { 124 | url POM_SCM_URL 125 | connection POM_SCM_CONNECTION 126 | developerConnection POM_SCM_DEV_CONNECTION 127 | } 128 | 129 | licenses { 130 | license { 131 | name POM_LICENCE_NAME 132 | url POM_LICENCE_URL 133 | distribution POM_LICENCE_DIST 134 | } 135 | } 136 | 137 | developers { 138 | developer { 139 | id POM_DEVELOPER_ID 140 | name POM_DEVELOPER_NAME 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | signing { 149 | required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") } 150 | sign configurations.archives 151 | } 152 | 153 | if (project.getPlugins().hasPlugin('com.android.application') || project.getPlugins(). 154 | hasPlugin('com.android.library')) { 155 | task install(type: Upload, dependsOn: assemble) { 156 | repositories.mavenInstaller { 157 | configuration = configurations.archives 158 | 159 | pom.groupId = GROUP 160 | pom.artifactId = POM_ARTIFACT_ID 161 | pom.version = VERSION_NAME 162 | 163 | pom.project { 164 | name POM_NAME 165 | packaging POM_PACKAGING 166 | description POM_DESCRIPTION 167 | url POM_URL 168 | 169 | scm { 170 | url POM_SCM_URL 171 | connection POM_SCM_CONNECTION 172 | developerConnection POM_SCM_DEV_CONNECTION 173 | } 174 | 175 | licenses { 176 | license { 177 | name POM_LICENCE_NAME 178 | url POM_LICENCE_URL 179 | distribution POM_LICENCE_DIST 180 | } 181 | } 182 | 183 | developers { 184 | developer { 185 | id POM_DEVELOPER_ID 186 | name POM_DEVELOPER_NAME 187 | } 188 | } 189 | } 190 | } 191 | } 192 | 193 | if (project.plugins.hasPlugin('kotlin-android')) { 194 | kotlinAndroidArtifactTasks() 195 | } else { 196 | androidArtifactTasks() 197 | } 198 | } else { 199 | install { 200 | repositories.mavenInstaller { 201 | pom.groupId = GROUP 202 | pom.artifactId = POM_ARTIFACT_ID 203 | pom.version = VERSION_NAME 204 | 205 | pom.project { 206 | name POM_NAME 207 | packaging POM_PACKAGING 208 | description POM_DESCRIPTION 209 | url POM_URL 210 | 211 | scm { 212 | url POM_SCM_URL 213 | connection POM_SCM_CONNECTION 214 | developerConnection POM_SCM_DEV_CONNECTION 215 | } 216 | 217 | licenses { 218 | license { 219 | name POM_LICENCE_NAME 220 | url POM_LICENCE_URL 221 | distribution POM_LICENCE_DIST 222 | } 223 | } 224 | 225 | developers { 226 | developer { 227 | id POM_DEVELOPER_ID 228 | name POM_DEVELOPER_NAME 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | javaArtifactTasks() 236 | } 237 | 238 | artifacts { 239 | archives sourceJar 240 | archives docJar 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 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 2019 Saket Narayan 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 | -------------------------------------------------------------------------------- /inboxrecyclerview/src/main/java/me/saket/inboxrecyclerview/InboxRecyclerView.kt: -------------------------------------------------------------------------------- 1 | package me.saket.inboxrecyclerview 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.graphics.Canvas 6 | import android.graphics.Rect 7 | import android.graphics.drawable.Drawable 8 | import android.os.Parcelable 9 | import android.util.AttributeSet 10 | import android.view.MotionEvent 11 | import android.view.Window 12 | import me.saket.inboxrecyclerview.InternalPageCallbacks.NoOp 13 | import me.saket.inboxrecyclerview.animation.ItemExpandAnimator 14 | import me.saket.inboxrecyclerview.dimming.AnimatedVisibilityColorDrawable 15 | import me.saket.inboxrecyclerview.dimming.DimPainter 16 | import me.saket.inboxrecyclerview.expander.AdapterIdBasedItem 17 | import me.saket.inboxrecyclerview.expander.AdapterIdBasedItemExpander 18 | import me.saket.inboxrecyclerview.expander.InboxItemExpander 19 | import me.saket.inboxrecyclerview.page.ExpandablePageLayout 20 | 21 | /** 22 | * A RecyclerView where items can expand and collapse to and from an [ExpandablePageLayout]. 23 | */ 24 | @Suppress("LeakingThis") 25 | open class InboxRecyclerView @JvmOverloads constructor( 26 | context: Context, 27 | attrs: AttributeSet? = null 28 | ) : ScrollSuppressibleRecyclerView(context, attrs), InternalPageCallbacks { 29 | 30 | /** Controls how [InboxRecyclerView] items are animated when the page is moving. */ 31 | var itemExpandAnimator: ItemExpandAnimator = ItemExpandAnimator.split() 32 | set(value) { 33 | val old = field 34 | field = value 35 | 36 | expandablePage?.let { page -> 37 | old.onDetachRecyclerView() 38 | value.onAttachRecyclerView(this, page) 39 | } 40 | } 41 | 42 | /** Controls how items are dimmed when the page is expanding/collapsing. */ 43 | var dimPainter: DimPainter = DimPainter.none() 44 | set(value) { 45 | val old = field 46 | field = value 47 | 48 | expandablePage?.let { page -> 49 | old.onDetachRecyclerView(resetDim = false) 50 | field.onAttachRecyclerView(this, page) 51 | } 52 | } 53 | 54 | @Suppress("unused") 55 | @Deprecated("Use dimPainter instead", ReplaceWith("dimPainter")) 56 | var tintPainter: DimPainter 57 | get() = dimPainter 58 | set(value) { dimPainter = value } 59 | 60 | /** Details about the currently expanded item. */ 61 | var expandedItemLoc: ExpandedItemLocation = ExpandedItemLocation.EMPTY 62 | 63 | @Suppress("unused") 64 | @Deprecated("Use expandedItemLoc instead", ReplaceWith("expandedItemLoc")) 65 | var expandedItem: ExpandedItemLocation 66 | get() = expandedItemLoc 67 | set(value) { expandedItemLoc = value } 68 | 69 | /** See [InboxItemExpander]. */ 70 | var itemExpander: InboxItemExpander<*> = AdapterIdBasedItemExpander(requireStableIds = true) 71 | set(value) { 72 | field = value 73 | field.recyclerView = this 74 | } 75 | 76 | /** 77 | * The expandable page to be used with this list. 78 | */ 79 | var expandablePage: ExpandablePageLayout? = null 80 | set(newPage) { 81 | val oldPage = field 82 | if (oldPage === newPage) { 83 | return 84 | } 85 | 86 | field = newPage 87 | 88 | // The old page may have gotten removed midway a collapse animation, 89 | // causing this list's layout to be stuck as disabled. Clear it here. 90 | if (newPage == null || newPage.isCollapsedOrCollapsing) { 91 | suppressLayout(false) 92 | } 93 | 94 | if (oldPage != null) { 95 | dimPainter.onDetachRecyclerView(resetDim = true) 96 | itemExpandAnimator.onDetachRecyclerView() 97 | oldPage.internalStateCallbacksForRecyclerView = NoOp() 98 | } 99 | 100 | if (newPage != null) { 101 | dimPainter.onAttachRecyclerView(this, newPage) 102 | itemExpandAnimator.onAttachRecyclerView(this, newPage) 103 | newPage.internalStateCallbacksForRecyclerView = this 104 | } 105 | } 106 | 107 | private var activityWindow: Window? = null 108 | private var activityWindowOrigBackground: Drawable? = null 109 | private var isFullyCoveredByPage: Boolean = false 110 | internal var dimDrawable: AnimatedVisibilityColorDrawable? = null 111 | 112 | init { 113 | // Because setters don't get called for default values. 114 | itemExpandAnimator = ItemExpandAnimator.split() 115 | dimPainter = DimPainter.listAndPage() 116 | itemExpander = itemExpander 117 | } 118 | 119 | override fun dispatchDraw(canvas: Canvas) { 120 | itemExpandAnimator.transformRecyclerViewCanvas(this, canvas) { 121 | super.dispatchDraw(canvas) 122 | } 123 | dimDrawable?.setBounds(0, 0, width, height) 124 | dimDrawable?.draw(canvas) 125 | } 126 | 127 | override fun onSaveInstanceState(): Parcelable { 128 | return itemExpander.saveState(super.onSaveInstanceState()) 129 | } 130 | 131 | override fun onRestoreInstanceState(state: Parcelable) { 132 | val superState = itemExpander.restoreState(state) 133 | super.onRestoreInstanceState(superState) 134 | } 135 | 136 | override fun onDetachedFromWindow() { 137 | expandablePage = null 138 | super.onDetachedFromWindow() 139 | } 140 | 141 | override fun dispatchTouchEvent(ev: MotionEvent): Boolean { 142 | if (expandablePage?.isCollapsed == false) { 143 | // Don't let the user open another item midway an animation. 144 | return true 145 | } 146 | 147 | val dispatched = super.dispatchTouchEvent(ev) 148 | return if (expandablePage?.isExpanded == true) { 149 | // Intentionally leak touch events behind just in case the content page has 150 | // a lower z-index than than this list. This is an ugly hack, but I cannot 151 | // think of a way to enforce view positions. Fortunately this hack will not 152 | // have any effect when the page is positioned at a higher z-index, where 153 | // it'll consume all touch events before they even reach this list. 154 | false 155 | 156 | } else { 157 | dispatched 158 | } 159 | } 160 | 161 | private fun ensureSetup(page: ExpandablePageLayout?): ExpandablePageLayout { 162 | requireNotNull(page) { "Did you forget to set InboxRecyclerView#expandablePage?" } 163 | requireNotNull(adapter) { "Adapter isn't attached yet!" } 164 | return page 165 | } 166 | 167 | /** 168 | * Expand an item by its adapter-ID. 169 | * 170 | * InboxRecyclerView uses adapter-IDs by default for expanding/collapsing items, 171 | * but you can also use any [Parcelable] object by using a custom item expander. 172 | * See [InboxItemExpander]. 173 | */ 174 | @JvmOverloads 175 | fun expandItem(adapterId: Long, immediate: Boolean = false) { 176 | val expander = itemExpander 177 | check(expander is AdapterIdBasedItemExpander) { 178 | "Can't expand an item by its adapter ID if a custom InboxItemExpander is set. " + 179 | "Call expandItem on your InboxItemExpander instead." 180 | } 181 | expander.expandItem(AdapterIdBasedItem(adapterId), immediate) 182 | } 183 | 184 | /** 185 | * Expand the page from the top. 186 | */ 187 | @JvmOverloads 188 | fun expandFromTop(immediate: Boolean = false) { 189 | itemExpander.expandItem(null, immediate) 190 | } 191 | 192 | internal fun expandOnceLaidOut(immediate: Boolean) { 193 | val page = ensureSetup(expandablePage) 194 | doOnLayout2 { 195 | page.doOnLayout2 { 196 | expandInternal(immediate) 197 | } 198 | } 199 | } 200 | 201 | private fun expandInternal(immediate: Boolean) { 202 | val page = expandablePage!! 203 | if (!page.isCollapsed) { 204 | // Expanding an item while another is already 205 | // expanding results in unpredictable animation. 206 | if (!expandedItemLoc.isNotEmpty()) { 207 | // Useful if the page was expanded immediately as a result of a (manual) 208 | // state restoration before this RecyclerView could restore its state. 209 | expandedItemLoc = itemExpander.captureExpandedItemInfo() 210 | } 211 | return 212 | } 213 | 214 | expandedItemLoc = itemExpander.captureExpandedItemInfo() 215 | if (immediate) { 216 | page.expandImmediately() 217 | } else { 218 | page.expand(expandedItemLoc) 219 | } 220 | } 221 | 222 | fun collapse() { 223 | val page = ensureSetup(expandablePage) 224 | 225 | if (page.isCollapsedOrCollapsing.not()) { 226 | // List items may have changed while the page was 227 | // expanded. Find the expanded item's location again. 228 | expandedItemLoc = itemExpander.captureExpandedItemInfo() 229 | page.collapse(expandedItemLoc) 230 | } 231 | } 232 | 233 | override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { 234 | super.onLayout(changed, l, t, r, b) 235 | 236 | // This is kind of a hack, but I want the layout to be frozen only after this list 237 | // has processed its initial batch of child Views. Otherwise this list stays empty 238 | // after a state restoration, until the page is collapsed. 239 | if (isLaidOut && childCount > 0 && expandablePage?.isExpandedOrExpanding == true) { 240 | suppressLayout(true) 241 | } 242 | } 243 | 244 | override fun onPageAboutToCollapse() { 245 | onPageBackgroundVisible() 246 | } 247 | 248 | override fun onPageCollapsed() { 249 | suppressLayout(false) 250 | expandedItemLoc = ExpandedItemLocation.EMPTY 251 | itemExpander.setItem(null) 252 | } 253 | 254 | override fun onPagePullStarted() { 255 | // List items may have changed while the page was expanded. Find the expanded item's location again. 256 | expandedItemLoc = itemExpander.captureExpandedItemInfo() 257 | } 258 | 259 | override fun onPagePull(deltaY: Float) { 260 | onPageBackgroundVisible() 261 | } 262 | 263 | override fun onPageRelease(collapseEligible: Boolean) { 264 | if (collapseEligible) { 265 | collapse() 266 | } 267 | } 268 | 269 | override fun onPageFullyCovered() { 270 | val invalidate = !isFullyCoveredByPage 271 | isFullyCoveredByPage = true 272 | if (invalidate) { 273 | invalidate() 274 | } 275 | 276 | activityWindow?.setBackgroundDrawable(null) 277 | } 278 | 279 | private fun onPageBackgroundVisible() { 280 | isFullyCoveredByPage = false 281 | invalidate() 282 | 283 | activityWindow?.setBackgroundDrawable(activityWindowOrigBackground) 284 | } 285 | 286 | override fun canScrollProgrammatically(): Boolean { 287 | val page = expandablePage 288 | return page == null || page.isCollapsed 289 | } 290 | 291 | override fun onDrawForeground(canvas: Canvas) { 292 | // If an item is expanded, its z-index changes from lower than 293 | // that of the scrollbars to a higher value and looks a bit abrupt. 294 | maybeHideScrollbarsAndRun { 295 | super.onDrawForeground(canvas) 296 | } 297 | } 298 | 299 | private inline fun maybeHideScrollbarsAndRun(crossinline run: () -> Unit) { 300 | val page = expandablePage 301 | if (page == null || page.isCollapsed || scrollBarStyle != SCROLLBARS_INSIDE_OVERLAY) { 302 | run() 303 | return 304 | } 305 | 306 | val wasVerticalEnabled = isVerticalScrollBarEnabled 307 | val wasHorizEnabled = isHorizontalScrollBarEnabled 308 | isVerticalScrollBarEnabled = false 309 | isHorizontalScrollBarEnabled = false 310 | run() 311 | isVerticalScrollBarEnabled = wasVerticalEnabled 312 | isHorizontalScrollBarEnabled = wasHorizEnabled 313 | } 314 | 315 | /** 316 | * Experimental: Reduce overdraw by 1 level by removing the Activity Window's 317 | * background when the [ExpandablePageLayout] is expanded. No point in drawing 318 | * it when it's not visible to the user. 319 | **/ 320 | // TODO: deprecate. 321 | fun optimizeActivityBackgroundOverdraw(activity: Activity) { 322 | activityWindow = activity.window 323 | activityWindowOrigBackground = activity.window.decorView.background 324 | } 325 | 326 | override fun setAdapter(adapter: Adapter<*>?) { 327 | val wasLayoutSuppressed = isLayoutSuppressed 328 | super.setAdapter(adapter) 329 | suppressLayout(wasLayoutSuppressed) // isLayoutSuppressed is reset when the adapter is changed. 330 | } 331 | 332 | override fun swapAdapter( 333 | adapter: Adapter<*>?, 334 | removeAndRecycleExistingViews: Boolean 335 | ) { 336 | val wasLayoutSuppressed = isLayoutSuppressed 337 | super.swapAdapter(adapter, removeAndRecycleExistingViews) 338 | suppressLayout(wasLayoutSuppressed) // isLayoutSuppressed is reset when the adapter is changed. 339 | } 340 | 341 | data class ExpandedItemLocation( 342 | // Index of the currently expanded item's 343 | // View. This is not the adapter index. 344 | val viewIndex: Int, 345 | 346 | // Original location of the currently expanded item. 347 | // Used for restoring states after collapsing. 348 | val locationOnScreen: Rect 349 | ) { 350 | 351 | internal fun isEmpty(): Boolean = this == EMPTY 352 | internal fun isNotEmpty(): Boolean = !isEmpty() 353 | 354 | companion object { 355 | internal val EMPTY = ExpandedItemLocation(viewIndex = -1, locationOnScreen = Rect(0, 0, 0, 0)) 356 | } 357 | } 358 | } 359 | 360 | // Only used for debugging. 361 | internal const val ANIMATION_START_DELAY = 0L 362 | --------------------------------------------------------------------------------