「下拉頭圖放大、釋放回彈」是社交類 App 標配交互。本文拆解 PullToZoomListView 核心邏輯——overScrollBy 攔截越界滑動、onScrollChanged 動態縮放、onTouchEvent.ACTION_UP 啟動回彈動畫,提供 **Java 與 Kotlin 完整雙版本代碼**,並演示 Kotlin Android Extensions 視圖綁定、Anko DSL 布局優勢。
ListView (含 HeaderView)
└── HeaderView (FrameLayout/LinearLayout)
└── ImageView (ScaleType.CENTER_CROP)
交互流程:
1. 用戶下拉 → ListView 觸底 → overScrollBy(deltaY<0) 攔截
2. deltaY 為負 → ImageView 高度 = 原高 - deltaY(變大)
3. 同時 HeaderView layout(0,0) 保持頂部貼邊
4. 用戶抬手 → onTouchEvent(ACTION_UP) → ResetAnimation(300ms)
5. 動畫插值器將高度從當前值線性插值回原始高度
6. requestLayout() 觸發重繪,完成平滑回彈
public class PullToZoomListView extends ListView {
private ImageView mImageView;
private int mImageViewHeight = -1;
private int mDrawableMaxHeight = -1;
public PullToZoomListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setPullToZoomImageView(ImageView imageView) {
this.mImageView = imageView;
mImageView.setScaleType(ScaleType.CENTER_CROP);
}
public void setViewBounds() {
if (mImageViewHeight == -1) {
mImageViewHeight = mImageView.getHeight();
if (mImageViewHeight < 0) {
mImageViewHeight = getContext().getResources()
.getDimensionPixelSize(R.dimen.size_default);
}
}
}
@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
boolean isCollapse = resizeOverScrollBy(deltaY);
return isCollapse ? true : super.overScrollBy(deltaX, deltaY, scrollX, scrollY,
scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
View header = (View) mImageView.getParent();
if (header.getTop() < 0 && mImageView.getHeight() > mImageViewHeight) {
mImageView.getLayoutParams().height = Math.max(
mImageView.getHeight() + header.getTop(), mImageViewHeight);
header.layout(header.getLeft(), 0, header.getRight(), header.getHeight());
mImageView.requestLayout();
}
}
private boolean resizeOverScrollBy(int deltaY) {
if (deltaY < 0) { // 下拉
mImageView.getLayoutParams().height = mImageView.getHeight() - deltaY;
mImageView.requestLayout();
} else { // 上拉回彈
if (mImageView.getHeight() > mImageViewHeight) {
mImageView.getLayoutParams().height = Math.max(
mImageView.getHeight() - deltaY, mImageViewHeight);
mImageView.requestLayout();
}
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_UP) {
ResetAnimation anim = new ResetAnimation(mImageView, mImageViewHeight);
anim.setDuration(300);
mImageView.startAnimation(anim);
}
return super.onTouchEvent(ev);
}
private class ResetAnimation extends Animation {
private final ImageView mView;
private final int targetHeight;
private final int originalHeight;
private final int extraHeight;
ResetAnimation(ImageView view, int targetHeight) {
this.mView = view;
this.targetHeight = targetHeight;
this.originalHeight = view.getHeight();
this.extraHeight = originalHeight - targetHeight;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
mView.getLayoutParams().height = (int) (originalHeight - extraHeight * interpolatedTime);
mView.requestLayout();
super.applyTransformation(interpolatedTime, t);
}
}
}
open class PullToZoomListView @JvmOverloads constructor(
context: Context, attrs: AttributeSet
) : ListView(context, attrs) {
private var mImageView: ImageView? = null
private var mImageViewHeight = -1
private val mDrawableMaxHeight = -1
fun setPullToZoomImageView(imageView: ImageView) {
mImageView = imageView
mImageView!!.scaleType = ScaleType.CENTER_CROP
}
fun setViewBounds() {
if (mImageViewHeight == -1) {
mImageViewHeight = mImageView!!.height
if (mImageViewHeight < 0) {
mImageViewHeight = context.resources.getDimensionPixelSize(R.dimen.size_default)
}
}
}
override fun overScrollBy(deltaX: Int, deltaY: Int, scrollX: Int, scrollY: Int,
scrollRangeX: Int, scrollRangeY: Int,
maxOverScrollX: Int, maxOverScrollY: Int, isTouchEvent: Boolean): Boolean {
val isCollapse = resizeOverScrollBy(deltaY)
return if (isCollapse) true else super.overScrollBy(deltaX, deltaY, scrollX, scrollY,
scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent)
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
val header = mImageView!!.parent as View
if (header.top < 0 && mImageView!!.height > mImageViewHeight) {
mImageView!!.layoutParams.height = Math.max(
mImageView!!.height + header.top, mImageViewHeight)
header.layout(header.left, 0, header.right, header.height)
mImageView!!.requestLayout()
}
}
private fun resizeOverScrollBy(deltaY: Int): Boolean {
if (deltaY < 0) {
mImageView!!.layoutParams.height = mImageView!!.height - deltaY
mImageView!!.requestLayout()
} else if (mImageView!!.height > mImageViewHeight) {
mImageView!!.layoutParams.height = Math.max(mImageView!!.height - deltaY, mImageViewHeight)
mImageView!!.requestLayout()
}
return false
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_UP) {
val anim = ResetAnimation(mImageView!!, mImageViewHeight).apply { duration = 300 }
mImageView!!.startAnimation(anim)
}
return super.onTouchEvent(ev)
}
inner class ResetAnimation(private val mView: ImageView, private val targetHeight: Int) : Animation() {
private val originalHeight: Int = mView.height
private val extraHeight: Int = originalHeight - targetHeight
override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
mView.layoutParams.height = (originalHeight - extraHeight * interpolatedTime).toInt()
mView.requestLayout()
super.applyTransformation(interpolatedTime, t)
}
}
}
public class MainActivity extends AppCompatActivity {
private PullToZoomListView lv;
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
lv = findViewById(R.id.main_lv);
View header = View.inflate(this, R.layout.layout_lv_header, null);
lv.addHeaderView(header);
ArrayAdapter adapter = new ArrayAdapter<>(this,
android.R.layout.simple_list_item_1,
new String[]{"騰訊","阿裡","百度","新浪","C","Java","PHP","FB","Twitter","XML","HTML"});
lv.setAdapter(adapter);
ImageView iv = header.findViewById(R.id.header_img);
lv.setPullToZoomImageView(iv);
}
@Override public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) lv.setViewBounds();
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportActionBar?.hide()
if (Build.VERSION.SDK_INT >= 21) supportActionBar?.elevation = 0f
val header = View.inflate(this, R.layout.layout_lv_header, null)
main_lv.addHeaderView(header) // 合成屬性直接用 ID
main_lv.adapter = ArrayAdapter(this,
android.R.layout.simple_list_item_1,
arrayOf("騰訊","阿裡","百度","新浪","C","Java","PHP","FB","Twitter","XML","HTML"))
main_lv.setPullToZoomImageView(header_img) // 合成屬性
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) main_lv.setViewBounds()
}
}
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
buildscript {
ext.kotlin_version = '1.8.20'
repositories { mavenCentral() }
dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" }
}
Code → Convert Java File to Kotlin File (Ctrl+Alt+Shift+K)Tools → Kotlin → Configure Kotlin in Projectimport kotlinx.android.synthetic.main.activity_main.* 後直接用 main_lv 代替 findViewById(R.id.main_lv)
// 代碼替代 XML,類型安全、可預覽
verticalLayout {
listView(R.id.main_lv) {
layoutParams = matchParent()
}
}
overScrollBy 攔截越界 → 動態修改 ImageView 高度 → onTouchEvent.UP 啟動回彈動畫PullToZoomScrollView 支援 RecyclerView/WebView 等任意可滾動 View