特点
- 可自定义展开段数(即自动吸附的高度)
- 到达吸附位置有仿 iOS 的回弹效果(可以参考apple地图)
- 可自定义背景半透明蒙层颜色
- 实现了
NestedScrollingParent
接口——支持嵌套滚动
演示
代码
BottomSheetDialog -- 继承该类实现自己的Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
abstract class BottomSheetDialog: DialogFragment() {
private var contentView: View? = null
override fun getTheme(): Int {
return R.style.com_SwithunBottomSheetDialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val contentViewWrapper = inflater.inflate(R.layout.bottom_sheet_dialog_layout, container, false)
contentView = contentViewWrapper
onCreateContentView(inflater, contentViewWrapper as BottomSheetDialogLayout, savedInstanceState)
contentViewWrapper.asIBottomSheetDialogLayoutSetting().init()
return contentView
}
abstract fun onCreateContentView(
inflater: LayoutInflater,
contentViewWrapper: BottomSheetDialogLayout,
savedInstanceState: Bundle?
)
}
bottom_sheet_dialog_layout -- Dialog基本布局
<?xml version="1.0" encoding="utf-8"?>
<com.swithun.swithunbottomsheetdialog.BottomSheetDialogLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#6BD001FF">
</com.swithun.swithunbottomsheetdialog.BottomSheetDialogLayout>
BottomSheetDialogLayout -- 实现Dialog的关键layout
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.ViewConfiguration
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import androidx.core.view.NestedScrollingParent3
import androidx.core.view.NestedScrollingParentHelper
import androidx.core.view.ViewCompat
import androidx.core.view.ViewCompat.NestedScrollType
import androidx.core.view.children
import androidx.customview.widget.ViewDragHelper.INVALID_POINTER
import kotlin.math.abs
class BottomSheetDialogLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr), NestedScrollingParent3, IBottomSheetDialogLayoutSetting {
private val firstView: View?
get() = children.firstOrNull()
private var lastMotionYForOnTouchEvent = 0
private var lastDownYForOnTouchEvent = 0
private var lastMotionYForInterceptTouchEvent = 0
private var lastDownYForInterceptEvent: Int = 0
private var scrollCauser: ScrollCauser = ScrollCauser.NONE
private val stateLowest get() = -height
private val stateHighest get() = 0
override var customStateList: List<CustomHeight>? = null
override var initState: Int = 0
override var stateListener: ((Int) -> Unit)? = null
private val wantStateList: List<Int>
get() {
val custom: List<CustomHeight>? = customStateList
return custom?.map {
when (it) {
is CustomHeight.Bottom -> stateLowest + it.add
is CustomHeight.Highest -> stateHighest - it.reduce
is CustomHeight.Number -> -it.value
is CustomHeight.Percent -> (stateLowest * it.f).toInt()
}
} ?: listOf(
stateLowest + 200,
(stateLowest * 0.4).toInt(),
stateHighest
)
}
private val safeStateList: List<Int>
get() {
val firstView = firstView ?: return wantStateList
val firstViewHeight = firstView.height
val list = mutableListOf<Int>()
val sortedStateList = wantStateList.sorted()
for (i in sortedStateList.indices) {
if (-(height - firstViewHeight) > sortedStateList[i]) {
list.add(sortedStateList[i])
}
}
if (list.size < sortedStateList.size) {
list.add(-(height - firstViewHeight))
}
return list
}
private val openState: Pair<Int, Int>
get() {
var oldState = safeStateList.size - 1
for (i in safeStateList.indices.reversed()) {
if (scrollY >= safeStateList[i]) {
return i to oldState
}
oldState = i
}
return 0 to oldState
}
private val parentHelper by lazy {
NestedScrollingParentHelper(this)
}
private val interceptTouchEventHelper = BottomSheetDialogInterceptTouchEventHelper()
private val velocityTracker = VelocityTracker.obtain()
private var activePointerId = INVALID_POINTER
private var animateStartY2EndY = AnimateValue(0, 0)
private val autoSettleAnimator = ValueAnimator().also {
it.interpolator = OvershootInterpolator(1f)
}
private var disableTouch = false
override var bgMask: Drawable
get() = this.background
set(value) {
this.background = value
}
override fun init() {
this.post {
val initState = initState
val list = safeStateList
scrollTo(scrollX, safeStateList[0] + 1)
val initScrollY = when {
initState >= list.size -> list.size - 1
initState < 0 -> 0
else -> initState
}
innerDoSettle(list[initScrollY])
}
autoSettleAnimator.addUpdateListener {
val process = (it.animatedValue as Float)
val passed = (animateStartY2EndY.endY - animateStartY2EndY.startY) * process
val newScrollY = animateStartY2EndY.startY + passed
Log.d(TAG, "[AUTO_SETTLE_ANIM] listener $process | $newScrollY")
Log.d(
TAG,
"[Fling Animate] $process $process | ${animateStartY2EndY.endY}, ${animateStartY2EndY.startY} | $passed $newScrollY"
)
this.scrollTo(scrollX, newScrollY.toInt())
}
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
for ((i, v) in safeStateList.withIndex()) {
if (v == t) {
stateListener?.invoke(i)
}
}
}
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
return true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
measureChildren(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED))
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
Log.i(TAG, "[onNestedScrollAccepted] $type")
parentHelper.onNestedScrollAccepted(child, target, axes, type)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
Log.d(TAG, "「onTouchEvent」 [t: ${event.actionMasked}] [y: ${event.y}]")
val touchY = event.y.toInt()
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
resetForDown()
recordDownForOnTouchEvent(touchY)
recordMotionForOnTouchEvent(touchY)
}
MotionEvent.ACTION_MOVE -> {
val moveY = lastMotionYForOnTouchEvent - touchY
scrollCauser = ScrollCauser.USER_TOUCH
scrollBy(0, moveY)
recordMotionForOnTouchEvent(touchY)
}
}
return true
}
override fun onStopNestedScroll(target: View, type: Int) {
Log.i(TAG, "[onStopNestedScroll] $type")
parentHelper.onStopNestedScroll(target, type)
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
Log.d(TAG, "「dispatchTouchEvent」 ${ev?.y} ${ev?.actionMasked}")
when(ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
resetForDown()
}
}
when {
ev.actionMasked == MotionEvent.ACTION_DOWN && ev.y + scrollY < verticalScrollRange().last -> {
disableTouch = true
Log.d(TAG, "[dispatchTouchEvent] [return] ${ev?.y} ${ev?.actionMasked}")
return true
}
ev.actionMasked == MotionEvent.ACTION_UP || ev.actionMasked == MotionEvent.ACTION_CANCEL -> {
disableTouch = false
}
else -> {}
}
if (disableTouch) return true
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
scrollCauser = ScrollCauser.NONE
autoSettleAnimator.cancel()
activePointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (openState.first != openState.second && scrollY != safeStateList[openState.first]) {
velocityTracker.computeCurrentVelocity(
1000, ViewConfiguration.get(context).scaledMaximumFlingVelocity.toFloat()
)
val initialVelocity: Float = velocityTracker.getYVelocity(activePointerId)
settle(initialVelocity)
}
activePointerId = INVALID_POINTER
}
}
velocityTracker.addMovement(ev)
return super.dispatchTouchEvent(ev)
}
private val touchSlop: Int = run {
val configuration = ViewConfiguration.get(context)
configuration.scaledTouchSlop
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
Log.d(TAG, "「onInterceptTouchEvent」 [t: ${ev.actionMasked}] [y: ${ev.y}]")
fun recordEvent(ev: MotionEvent) {
recordMotionYForOnInterceptTouchEvent(ev.y.toInt())
recordMotionForOnTouchEvent(ev.y.toInt())
}
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
recordDownYForOnInterceptTouchEvent(ev.y.toInt())
recordDownForOnTouchEvent(ev.y.toInt())
}
MotionEvent.ACTION_MOVE -> {
val y = ev.y.toInt()
val yDiffDown = abs(y - lastDownYForInterceptEvent)
if (yDiffDown > touchSlop) {
val yDiffMotion = lastMotionYForInterceptTouchEvent - ev.y.toInt()
if (yDiffMotion > 0) {
if ((yDiffMotion + scrollY) in verticalScrollRange()) {
Log.d(
TAG,
"[onInterceptTouchEvent] [return] $yDiffMotion $scrollY | ${verticalScrollRange()}"
)
recordEvent(ev)
return true
}
} else if (yDiffMotion < 0) {
children.forEach { child ->
if (!interceptTouchEventHelper.isTouchNestedScrollChild(
this, child, ev.x.toInt(), ev.y.toInt()
)
) {
Log.d(
TAG,
"[onInterceptTouchEvent] [return2] $yDiffMotion $scrollY | ${verticalScrollRange()}"
)
recordEvent(ev)
return true
}
}
}
}
}
}
recordEvent(ev)
return super.onInterceptTouchEvent(ev)
}
private fun doSettle(isDown: Boolean, reason: String) {
val up = openState.second
val down = openState.first
val animateY = if (isDown) {
safeStateList[down]
} else {
safeStateList[up]
}
Log.d(
TAG, "[autoSettle] for $reason [up: $up] [down: $down] [isD: $isDown] [toY: $animateY]"
)
innerDoSettle(animateY)
}
private fun innerDoSettle(animateY: Int) {
scrollCauser = ScrollCauser.AUTO_SETTLE
setAnimatedValue { AnimateValue(scrollY, animateY) }
autoSettleAnimator.cancel()
autoSettleAnimator.duration = 400
autoSettleAnimator.setFloatValues(0f, 1f)
autoSettleAnimator.start()
}
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
@NestedScrollType type: Int,
consumed: IntArray
) {
Log.i(TAG, "「onNestedScroll」 $dyUnconsumed")
if (dyUnconsumed < 0) {
when (type) {
ViewCompat.TYPE_NON_TOUCH -> {}
ViewCompat.TYPE_TOUCH -> {
doNestedScroll(dyUnconsumed, consumed, type)
}
}
}
}
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int
) {
Log.i(TAG, "[onNestedScroll]2")
}
private fun verticalScrollRange(): IntRange {
return safeStateList[0]..safeStateList.last()
}
override fun onNestedPreScroll(
target: View, dx: Int, dy: Int, consumed: IntArray, @NestedScrollType type: Int
) {
Log.i(TAG, "「onNestedPreScroll」 [dy: $dy] [s: $scrollY]")
when (type) {
ViewCompat.TYPE_NON_TOUCH -> {
}
ViewCompat.TYPE_TOUCH -> {
if (scrollY >= safeStateList.last()) {
} else {
if (dy > 0) {
doNestedScroll(dy, consumed, type)
} else {
}
}
}
}
}
private fun doNestedScroll(
parentWantToConsume: Int, consumed: IntArray, @NestedScrollType type: Int
) {
val nextY = scrollY + parentWantToConsume
val maxNextY = verticalScrollRange().last
val minNextY = verticalScrollRange().first
val safeNextY = if (minNextY > maxNextY) {
0
} else {
nextY.coerceIn(minNextY..maxNextY)
}
when (val safePrentWantToConsume = safeNextY - scrollY) {
0 -> Log.i(TAG, "[doNestedPreScroll]#false ($type)")
else -> {
Log.i(TAG, "[doNestedPreScroll]#true ($type) ${scrollY} ${firstView?.height}")
consumed[1] = safePrentWantToConsume
scrollBy(0, safePrentWantToConsume)
}
}
}
override fun scrollTo(x: Int, y: Int) {
Log.d(TAG, "[scrollTo] $y $scrollCauser")
when {
scrollCauser == ScrollCauser.AUTO_SETTLE -> {
super.scrollTo(x, y)
return
}
else -> {
if (y > verticalScrollRange().last) {
doSettle(false, "scrollTo Top")
return
} else {
if (y in verticalScrollRange()) {
Log.d(TAG, "[scrollTo]#[true] $y ${firstView?.height}")
super.scrollTo(x, y)
} else {
Log.d(TAG, "[scrollTo]#[false] $y ${firstView?.height}")
}
}
}
}
}
private fun resetForDown() {
scrollCauser = ScrollCauser.NONE
autoSettleAnimator.cancel()
stopNestedScroll()
}
private fun recordDownYForOnInterceptTouchEvent(y: Int) {
lastDownYForInterceptEvent = y
}
private fun recordMotionYForOnInterceptTouchEvent(y: Int) {
lastMotionYForInterceptTouchEvent = y
}
private fun recordMotionForOnTouchEvent(touchY: Int) {
lastMotionYForOnTouchEvent = touchY
}
private fun recordDownForOnTouchEvent(touchY: Int) {
lastDownYForOnTouchEvent = touchY
}
private fun settle(fl: Float) {
if (fl > 0) {
doSettle(true, "settle 速度向下")
} else if (fl < 0) {
doSettle(false, "settle 速度向上")
} else {
if (Math.abs(scrollY - openState.first) > Math.abs(scrollY - openState.second)) {
doSettle(true, "settle 速度为0 down")
} else {
doSettle(false, "settle 速度为0 up")
}
}
}
private fun setAnimatedValue(reducer: AnimateValue.() -> AnimateValue) {
this.animateStartY2EndY = reducer(this.animateStartY2EndY)
}
data class AnimateValue(
var startY: Int,
var endY: Int,
)
interface ScrollCauser {
object NONE : ScrollCauser
object USER_TOUCH : ScrollCauser
object AUTO_SETTLE : ScrollCauser
}
sealed interface CustomHeight {
class Number(val value: Int) : CustomHeight
class Percent(val f: Float) : CustomHeight
class Bottom(val add: Int) : CustomHeight
class Highest(val reduce: Int) : CustomHeight
}
companion object {
private const val TAG = "ParentNestedScrollView"
}
}
代码仓库
github