├── 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 | 
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 | 
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 |
22 |
23 |
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://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 |
--------------------------------------------------------------------------------