Skip to content

Instantly share code, notes, and snippets.

@iosandroiddev
Created June 1, 2024 07:04
Show Gist options
  • Save iosandroiddev/92ef0101ef67fdde6f84990c3ad6bde5 to your computer and use it in GitHub Desktop.
Save iosandroiddev/92ef0101ef67fdde6f84990c3ad6bde5 to your computer and use it in GitHub Desktop.
A Kotlin Scroll View with Sticky Views
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