实现监听滚动状态、滚动页码、滚动到最底部的ScrollView/android
提出问题
最近项目业务上有这样一个需求,原生实现阅读交易协议长文本,要求显示页码,阅读完成才能高亮同意按钮。采用ScrollView + TextView的方案只能实现展示和滚动阅读,对于页码和是否滚动到最底部的需求,需要进行扩展实现。
分析问题
我们知道ScrollView最多只能包含一个直接孩子,而且自身也有滚动回调方法,所以我们可以在滚动回调方法中做文章。,
解决问题
先看效果图:
下面直接上代码:
自定义ScrollView:
package com.bg.blogcodeproject.widget.scroll;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.widget.NestedScrollView;
/**
* @Desc: 带滚动监听的ScrollView:
* 监听实时滚动位置,
* 监听滚动状态变化、
* 监听滚动页码变化、
* 监听是否滚动到底部
* @Author:bg
* @Date: 2022/3/22 17:00
*/
public class ObservableScrollView extends NestedScrollView {
/**
* 每隔80毫秒检查一次滚动状态变化
*/
private static final int CHECK_SCROLL_DELAY_MILLIS = 80;
private static final int MSG_SCROLL = 2468;
private static final String TAG = "ObservableScrollView";
/**
* 当前是否为触摸状态
*/
private boolean mTouched = false;
/**
* 当前是否滚动到底部
*/
private boolean mScrolledEnd = false;
/**
* 当前页码
*/
private int mPage = 1;
/**
* 总页码
*/
private int mTotalPage = 0;
/**
* 判断是否滑动到下一页的比例,取值范围区间(0,1)
*/
private static final float NEXT_PAGE_RATIO = 0.5f;
/**
* 记录当前的滚动状态
*/
private ScrollState mScrollState = ScrollState.IDLE;
private OnScrollStateChangedListener mOnScrollStateChangedListener;
private OnScrolledEndChangedListener mOnScrolledEndChangedListener;
private OnScrollPageNumChangedListener mOnScrollPageNumChangedListener;
public ObservableScrollView(@NonNull Context context) {
this(context, null);
}
public ObservableScrollView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ObservableScrollView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 视图树布局完成时计算是否滚动到底的初始状态
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 计算是否滚动到底部
computeScrolledEnd(ObservableScrollView.this.getScrollY());
// 计算总页数
computeTotalPage();
// 移除视图树布局监听器
getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
/**
* 计算是否滚动到底
* 1,ScrollView高度使用match_parent
* 1,ScrollView高度使用wrap_content
* 1,ScrollView高度写死dp
*
* @param scrollY 滚动位置
*/
private void computeScrolledEnd(int scrollY) {
// ScrollView有且只有一个孩子
View child = getChildAt(0);
int childHeight = child.getHeight();
// ScrollView自身高度
int height = getHeight();
// ScrollView自身高度 + scrollY 达到孩子的高度,就是滚动到底了
boolean scrolledEnd = height + scrollY >= childHeight;
setScrolledEnd(scrolledEnd);
}
private void setScrolledEnd(boolean end) {
mScrolledEnd = end;
Log.i(TAG, "mScrolledEnd = " + mScrolledEnd);
if (mOnScrolledEndChangedListener != null) {
mOnScrolledEndChangedListener.onScrolledEnd(mScrolledEnd);
}
}
private void computeTotalPage() {
// ScrollView有且只有一个孩子
View child = getChildAt(0);
// 总高度就是孩子内容的高度
int totalHeight = child.getHeight();
Log.i(TAG, "totalPageHeight = " + totalHeight);
// 单页的高度就是ScrollView自身高度
int pageHeight = getHeight();
Log.i(TAG, "pageHeight = " + pageHeight);
// 计算总页码,向上取整
int totalPage = (int) Math.ceil(totalHeight * 1.0f / pageHeight);
setPageNumChanged(mPage, totalPage);
}
private void setPageNumChanged(int page, int totalPage) {
if (mPage != page || mTotalPage != totalPage) {
mPage = page;
mTotalPage = totalPage;
Log.i(TAG, "mPage = " + mPage);
Log.i(TAG, "mTotalPage = " + mTotalPage);
if (mOnScrollPageNumChangedListener != null) {
mOnScrollPageNumChangedListener.onScrollPageNumChanged(mPage, mTotalPage);
}
}
}
/**
* 滚动事件回调
*
* @param l Current horizontal scroll origin.
* @param t Current vertical scroll origin.
* @param oldl Previous horizontal scroll origin.
* @param oldt Previous vertical scroll origin.
*/
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
//计算滚动状态
computeScrollStateOnScroll(t, oldt);
//计算是否滚动到底部
computeScrolledEnd(t);
// 计算当前页码
computeScrollPage(t);
// 滚动位置回调
setScrollPositionChanged(l, t, oldl, oldt);
}
private void computeScrollStateOnScroll(int t, int oldt) {
int scrolledY = t - oldt;
if (mTouched) {
setScrollState(scrolledY > 0 ? ScrollState.TOUCH_SCROLL_DOWN :
ScrollState.TOUCH_SCROLL_UP);
} else {
setScrollState(scrolledY > 0 ? ScrollState.FLING_DOWN :
ScrollState.FLING_UP);
restartCheckTimer();
}
}
private void setScrollState(ScrollState state) {
if (state != mScrollState) {
mScrollState = state;
Log.i(TAG, "mScrollState = " + mScrollState);
if (mOnScrollStateChangedListener != null) {
mOnScrollStateChangedListener.onScrollStateChanged(mScrollState);
}
}
}
private void restartCheckTimer() {
mHandler.removeMessages(MSG_SCROLL);
mHandler.sendEmptyMessageDelayed(MSG_SCROLL, CHECK_SCROLL_DELAY_MILLIS);
}
private void computeScrollPage(int scrollY) {
// 单页的高度就是ScrollView自身高度
int pageHeight = getHeight();
float result = scrollY * 1.0f / pageHeight + 1.0f;
int page;
if (result > mTotalPage - 1) {
// 最后一页需要单独判断,存在不满一屏的情况
// TODO: 2022/3/22 还没想到更好办法,先简单粗暴处理
page = mTotalPage;
} else {
// 其他页,四舍五入,下一页滚动到50%认为已到下一页
page = (int) (result + NEXT_PAGE_RATIO);
}
setPageNumChanged(page, mTotalPage);
}
private void setScrollPositionChanged(int l, int t, int oldl, int oldt) {
Log.i(TAG, "scrollY = " + t);
if (mOnScrollStateChangedListener != null) {
mOnScrollStateChangedListener.onScrollChanged(l, t, oldl, oldt);
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
handleDownEvent();
}
return super.onInterceptTouchEvent(ev);
}
private void handleDownEvent() {
// 手指按下时,为触摸状态
mTouched = true;
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
handleUpEvent();
break;
default:
}
return super.onTouchEvent(ev);
}
private void handleUpEvent() {
// 手指抬起时,不再是触摸状态
mTouched = false;
restartCheckTimer();
}
@Override
protected void onDetachedFromWindow() {
mHandler.removeCallbacksAndMessages(null);
super.onDetachedFromWindow();
}
public void setOnScrollStateChangedListener(OnScrollStateChangedListener listener) {
this.mOnScrollStateChangedListener = listener;
}
public void setOnScrollToEndChangedListener(OnScrolledEndChangedListener listener) {
this.mOnScrolledEndChangedListener = listener;
}
public void setOnScrollPageNumChangedListener(OnScrollPageNumChangedListener listener) {
this.mOnScrollPageNumChangedListener = listener;
}
private final Handler mHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
/**
* 记录上一次滚动的位置
*/
private int mLastY = Integer.MIN_VALUE;
/**
* @param msg A {@link Message Message} object
* @return True if no further handling is desired
*/
@Override
public boolean handleMessage(@NonNull Message msg) {
if (MSG_SCROLL == msg.what) {
int scrollY = getScrollY();
// 未触摸未发生滚动,就是空闲状态
if (!mTouched && mLastY == scrollY) {
mLastY = Integer.MIN_VALUE;
setScrollState(ScrollState.IDLE);
} else {
// 触摸或正在滚动,检测滚动状态
mLastY = scrollY;
restartCheckTimer();
}
return true;
}
return false;
}
});
public enum ScrollState {
/**
* 停止滚动,空闲静止状态
*/
IDLE,
/**
* 向上触摸滚动,手指触摸滚动状态
*/
TOUCH_SCROLL_UP,
/**
* 向下触摸滚动,手指触摸滚动状态
*/
TOUCH_SCROLL_DOWN,
/**
* 向上飞滚,手指离开后惯性滚动状态
*/
FLING_UP,
/**
* 向下飞滚,手指离开后惯性滚动状态
*/
FLING_DOWN
}
public interface OnScrollStateChangedListener {
/**
* 滚动状态变化的回调
*
* @param state 滚动状态
*/
void onScrollStateChanged(ScrollState state);
/**
* 滚动事件回调
*
* @param x Current horizontal scroll origin.
* @param y Current vertical scroll origin.
* @param oldX Previous horizontal scroll origin.
* @param oldY Previous vertical scroll origin.
*/
void onScrollChanged(int x, int y, int oldX, int oldY);
}
public interface OnScrolledEndChangedListener {
/**
* 是否滚动到底的回调
*
* @param end 是否滚动到底
*/
void onScrolledEnd(boolean end);
}
public interface OnScrollPageNumChangedListener {
/**
* 页码变化的回调
*
* @param pages 当前页码
* @param totalPages 总页码
*/
void onScrollPageNumChanged(int pages, int totalPages);
}
}
在Activity中的使用如下,布局文件很简单就不罗列出来了:
package com.bg.blogcodeproject;
import android.os.Bundle;
import android.util.Log;
import com.bg.blogcodeproject.widget.scroll.ObservableScrollView;
import java.util.Locale;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatButton;
import androidx.appcompat.widget.AppCompatTextView;
public class CustomScrollActivity extends AppCompatActivity implements
ObservableScrollView.OnScrollStateChangedListener,
ObservableScrollView.OnScrolledEndChangedListener,
ObservableScrollView.OnScrollPageNumChangedListener {
private static final String TAG = "CustomScrollActivity";
private AppCompatTextView tvPages;
private AppCompatTextView tvScrollY;
private AppCompatButton btnEnd;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_custom_scroll);
tvPages = findViewById(R.id.tv_pages);
tvScrollY = findViewById(R.id.tv_scroll);
btnEnd = findViewById(R.id.btn_end);
ObservableScrollView osv = findViewById(R.id.osv_container);
osv.setOnScrollStateChangedListener(this);
osv.setOnScrollToEndChangedListener(this);
osv.setOnScrollPageNumChangedListener(this);
}
/**
* 滚动状态变化的回调
*
* @param state 滚动状态
*/
@Override
public void onScrollStateChanged(ObservableScrollView.ScrollState state) {
Log.i(TAG, "onScrollStateChanged: " + state.name());
}
/**
* 滚动事件回调
*/
@Override
public void onScrollChanged(int x, int y, int oldX, int oldY) {
tvScrollY.setText(String.format(Locale.CHINA, "滚动位置:%d", y));
}
/**
* 是否滚动到底的回调
*
* @param end 是否滚动到底
*/
@Override
public void onScrolledEnd(boolean end) {
btnEnd.setAlpha(end ? 1f : 0.3f);
}
/**
* 页码变化的回调
*
* @param pages 当前页码
* @param totalPages 总页码
*/
@Override
public void onScrollPageNumChanged(int pages, int totalPages) {
tvPages.setText(String.format(Locale.CHINA, "页码:%d / %d", pages, totalPages));
}
}