Created
June 1, 2024 07:04
-
-
Save iosandroiddev/92ef0101ef67fdde6f84990c3ad6bde5 to your computer and use it in GitHub Desktop.
A Kotlin Scroll View with Sticky Views
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.content.Context | |
import android.graphics.Canvas | |
import android.util.AttributeSet | |
import android.view.MotionEvent | |
import android.view.View | |
import android.view.ViewGroup | |
import android.widget.ScrollView | |
import kotlin.math.min | |
class StickyScrollView @JvmOverloads constructor( | |
context: Context, | |
attrs: AttributeSet? = null, | |
defStyle: Int = android.R.attr.scrollViewStyle | |
) : ScrollView(context, attrs, defStyle) { | |
private var stickyViews: ArrayList<View> = ArrayList() | |
private var currentlyStickingView: View? = null | |
private var stickyViewTopOffset = 0f | |
private var stickyViewLeftOffset = 0 | |
private var redirectTouchesToStickyView = false | |
private var clippingToPadding = false | |
private var clipToPaddingHasBeenSet = false | |
private val invalidateRunnable: Runnable = object : Runnable { | |
override fun run() { | |
currentlyStickingView?.let { currentlyStickingView -> | |
val l = getLeftForViewRelativeOnlyChild(currentlyStickingView) | |
val t = getBottomForViewRelativeOnlyChild(currentlyStickingView) | |
val r = getRightForViewRelativeOnlyChild(currentlyStickingView) | |
val b = (scrollY + (currentlyStickingView.height + stickyViewTopOffset)).toInt() | |
postInvalidate(l, t, r, b) | |
} | |
postDelayed(this, 16) | |
} | |
} | |
fun setup() { | |
stickyViews = ArrayList() | |
} | |
private fun getLeftForViewRelativeOnlyChild(v: View): Int { | |
var viewRef = v | |
var left = viewRef.left | |
while (viewRef.parent !== getChildAt(0)) { | |
viewRef = viewRef.parent as View | |
left += viewRef.left | |
} | |
return left | |
} | |
private fun getTopForViewRelativeOnlyChild(v: View): Int { | |
var viewRef = v | |
var top = viewRef.top | |
while (viewRef.parent !== getChildAt(0)) { | |
viewRef = viewRef.parent as View | |
top += viewRef.top | |
} | |
return top | |
} | |
private fun getRightForViewRelativeOnlyChild(v: View): Int { | |
var viewRef = v | |
var right = viewRef.right | |
while (viewRef.parent !== getChildAt(0)) { | |
viewRef = viewRef.parent as View | |
right += viewRef.right | |
} | |
return right | |
} | |
private fun getBottomForViewRelativeOnlyChild(v: View): Int { | |
var viewRef = v | |
var bottom = viewRef.bottom | |
while (viewRef.parent !== getChildAt(0)) { | |
viewRef = viewRef.parent as View | |
bottom += viewRef.bottom | |
} | |
return bottom | |
} | |
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { | |
super.onLayout(changed, l, t, r, b) | |
if (!clipToPaddingHasBeenSet) { | |
clippingToPadding = true | |
} | |
notifyHierarchyChanged() | |
} | |
override fun setClipToPadding(clipToPadding: Boolean) { | |
super.setClipToPadding(clipToPadding) | |
clippingToPadding = clipToPadding | |
clipToPaddingHasBeenSet = true | |
} | |
override fun addView(child: View) { | |
super.addView(child) | |
findStickyViews(child) | |
} | |
override fun addView(child: View, index: Int) { | |
super.addView(child, index) | |
findStickyViews(child) | |
} | |
override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) { | |
super.addView(child, index, params) | |
findStickyViews(child) | |
} | |
override fun addView(child: View, width: Int, height: Int) { | |
super.addView(child, width, height) | |
findStickyViews(child) | |
} | |
override fun addView(child: View, params: ViewGroup.LayoutParams) { | |
super.addView(child, params) | |
findStickyViews(child) | |
} | |
override fun dispatchDraw(canvas: Canvas) { | |
super.dispatchDraw(canvas) | |
currentlyStickingView?.let { currentlyStickingView -> | |
canvas.save() | |
canvas.translate( | |
(paddingLeft + stickyViewLeftOffset).toFloat(), | |
scrollY + stickyViewTopOffset + (if (clippingToPadding) paddingTop else 0) | |
) | |
canvas.clipRect( | |
0f, (if (clippingToPadding) -stickyViewTopOffset else 0f), | |
(width - stickyViewLeftOffset).toFloat(), | |
(currentlyStickingView.height + 1).toFloat() | |
) | |
canvas.clipRect( | |
0f, | |
(if (clippingToPadding) -stickyViewTopOffset else 0f), | |
width.toFloat(), | |
currentlyStickingView.height.toFloat() | |
) | |
if (getStringTagForView(currentlyStickingView).contains(FLAG_HAS_TRANSPARENCY)) { | |
showView(currentlyStickingView) | |
currentlyStickingView.draw(canvas) | |
hideView(currentlyStickingView) | |
} else { | |
currentlyStickingView.draw(canvas) | |
} | |
canvas.restore() | |
} | |
} | |
override fun dispatchTouchEvent(ev: MotionEvent): Boolean { | |
if (ev.action == MotionEvent.ACTION_DOWN) { | |
redirectTouchesToStickyView = true | |
} | |
val currentlyStickingViewReference = currentlyStickingView?.let { | |
true | |
} ?: false | |
if (redirectTouchesToStickyView) { | |
redirectTouchesToStickyView = currentlyStickingViewReference | |
if (redirectTouchesToStickyView) { | |
redirectTouchesToStickyView = currentlyStickingView?.let { viewRef -> | |
ev.y <= (viewRef.height + stickyViewTopOffset) && ev.x >= getLeftForViewRelativeOnlyChild( | |
viewRef | |
) && ev.x <= getRightForViewRelativeOnlyChild(viewRef) | |
} ?: false | |
} | |
} else if (currentlyStickingView == null) { | |
redirectTouchesToStickyView = false | |
} | |
if (redirectTouchesToStickyView) { | |
currentlyStickingView?.let { | |
ev.offsetLocation( | |
0f, | |
-1 * ((scrollY + stickyViewTopOffset) - getTopForViewRelativeOnlyChild( | |
it | |
)) | |
) | |
} | |
} | |
return super.dispatchTouchEvent(ev) | |
} | |
private var hasNotDoneActionDown = true | |
init { | |
setup() | |
} | |
override fun onTouchEvent(ev: MotionEvent): Boolean { | |
if (redirectTouchesToStickyView) { | |
currentlyStickingView?.let { | |
ev.offsetLocation( | |
0f, | |
((scrollY + stickyViewTopOffset) - getTopForViewRelativeOnlyChild( | |
it | |
)) | |
) | |
} | |
} | |
if (ev.action == MotionEvent.ACTION_DOWN) { | |
hasNotDoneActionDown = false | |
} | |
if (hasNotDoneActionDown) { | |
val down = MotionEvent.obtain(ev) | |
down.action = MotionEvent.ACTION_DOWN | |
super.onTouchEvent(down) | |
hasNotDoneActionDown = false | |
} | |
if (ev.action == MotionEvent.ACTION_UP || ev.action == MotionEvent.ACTION_CANCEL) { | |
hasNotDoneActionDown = true | |
} | |
return super.onTouchEvent(ev) | |
} | |
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { | |
super.onScrollChanged(l, t, oldl, oldt) | |
doTheStickyThing() | |
} | |
private fun doTheStickyThing() { | |
var viewThatShouldStick: View? = null | |
var approachingView: View? = null | |
for (v in stickyViews) { | |
val viewTop = | |
getTopForViewRelativeOnlyChild(v) - scrollY + (if (clippingToPadding) 0 else paddingTop) | |
if (viewTop <= 0) { | |
if (viewThatShouldStick == null || viewTop > (getTopForViewRelativeOnlyChild( | |
viewThatShouldStick | |
) - scrollY + (if (clippingToPadding) 0 else paddingTop)) | |
) { | |
viewThatShouldStick = v | |
} | |
} else { | |
if (approachingView == null || viewTop < (getTopForViewRelativeOnlyChild( | |
approachingView | |
) - scrollY + (if (clippingToPadding) 0 else paddingTop)) | |
) { | |
approachingView = v | |
} | |
} | |
} | |
if (viewThatShouldStick != null) { | |
stickyViewTopOffset = (if (approachingView == null) 0 else min( | |
0.0, | |
(getTopForViewRelativeOnlyChild(approachingView) - scrollY + (if (clippingToPadding) 0 else paddingTop) - viewThatShouldStick.height).toDouble() | |
)).toFloat() | |
if (viewThatShouldStick !== currentlyStickingView) { | |
if (currentlyStickingView != null) { | |
stopStickingCurrentlyStickingView() | |
} | |
// only compute the left offset when we start sticking. | |
stickyViewLeftOffset = getLeftForViewRelativeOnlyChild(viewThatShouldStick) | |
startStickingView(viewThatShouldStick) | |
} | |
} else if (currentlyStickingView != null) { | |
stopStickingCurrentlyStickingView() | |
} | |
} | |
private fun startStickingView(viewThatShouldStick: View) { | |
currentlyStickingView = viewThatShouldStick | |
if (getStringTagForView(currentlyStickingView).contains(FLAG_HAS_TRANSPARENCY)) { | |
hideView(currentlyStickingView) | |
} | |
if ((currentlyStickingView?.tag as? String)?.contains(FLAG_NON_CONSTANT) == true) { | |
post(invalidateRunnable) | |
} | |
} | |
private fun stopStickingCurrentlyStickingView() { | |
if (getStringTagForView(currentlyStickingView).contains(FLAG_HAS_TRANSPARENCY)) { | |
showView(currentlyStickingView) | |
} | |
currentlyStickingView = null | |
removeCallbacks(invalidateRunnable) | |
} | |
/** | |
* Notify that the sticky attribute has been added or removed from one or more views in the View hierarchy | |
*/ | |
fun notifyStickyAttributeChanged() { | |
notifyHierarchyChanged() | |
} | |
private fun notifyHierarchyChanged() { | |
if (currentlyStickingView != null) { | |
stopStickingCurrentlyStickingView() | |
} | |
stickyViews.clear() | |
findStickyViews(getChildAt(0)) | |
doTheStickyThing() | |
invalidate() | |
} | |
private fun findStickyViews(v: View) { | |
if (v is ViewGroup) { | |
val vg = v | |
for (i in 0 until vg.childCount) { | |
val tag = getStringTagForView(vg.getChildAt(i)) | |
if (tag != null && tag.contains(STICKY_TAG)) { | |
stickyViews.add(vg.getChildAt(i)) | |
} else if (vg.getChildAt(i) is ViewGroup) { | |
findStickyViews(vg.getChildAt(i)) | |
} | |
} | |
} else { | |
val tag = v.tag as? String | |
tag?.let { tagRef -> | |
if (tagRef.contains(STICKY_TAG)) { | |
v.let { | |
stickyViews.add(it) | |
} | |
} | |
} | |
} | |
} | |
private fun getStringTagForView(v: View?): String { | |
val tagObject = v?.tag | |
return tagObject.toString() | |
} | |
private fun hideView(v: View?) { | |
v?.alpha = 0f | |
} | |
private fun showView(v: View?) { | |
v?.alpha = 1f | |
} | |
companion object { | |
/** | |
* Tag for views that should stick and have constant drawing. e.g. TextViews, ImageViews etc | |
*/ | |
const val STICKY_TAG: String = "sticky" | |
/** | |
* Flag for views that should stick and have non-constant drawing. e.g. Buttons, ProgressBars etc | |
*/ | |
const val FLAG_NON_CONSTANT: String = "-nonconstant" | |
/** | |
* Flag for views that have aren't fully opaque | |
*/ | |
const val FLAG_HAS_TRANSPARENCY: String = "-hastransparency" | |
/** | |
* Default height of the shadow peeking out below the stuck view. | |
*/ | |
private const val DEFAULT_SHADOW_HEIGHT = 10 // dp; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment