天天看點

打造QQ空間頭部視差ListView

    QQ空間相信大家都用過,是否覺得它的下拉重新整理很酷呢?今天就來自己實作這個控件。

本文主要是講思想和一些api,想要使用此效果到項目中的同學請點選這裡帶動畫的下拉重新整理RecyclerView

效果圖:

打造QQ空間頭部視差ListView
對實作過程不感興趣的童鞋可以直接到文章底部粘帖代碼,代碼中有詳細注釋。
           

    要實作這樣的效果,需要重寫ListView控件,并在ListView中處理下拉事件。

    首先我們進行ListView最基礎的操作,就是設定擴充卡顯示頭部布局和一個清單出來,這些操作相信大家都會寫,直接貼出代碼:

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.sch.headzoomlistviewdemo.HeadZoomListView
        android:id="@+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</RelativeLayout>
           

    上面這個是Activity的内容布局,其中包含一個自定義的ListView控件

com.example.sch.headzoomlistviewdemo.HeadZoomListView

list_view_header.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/iv_hander"
        android:scaleType="centerCrop"
        android:layout_width="match_parent"
        android:layout_height="162dp"
        android:src="@mipmap/banner1" />

</RelativeLayout>
           

    此布局做為ListView的頭部,裡面隻包含一個ImageView,可以看到給ImageView設定了

android:scaleType="centerCrop"

屬性,表示按比例擴大此ImageView的圖檔資源的size居中顯示,使得圖檔長(寬)等于或大于View的長(寬) ,但是此屬性隻有在ImageView使用

android:src=""

屬性或者

setImageBitmap()

或者

setImageResource()

方法設定圖檔時才有效,使用

background

設定背景是無效的。

    

android:scaleType

的各種值的含義可以參考 ImageView.ScaleType設定圖解

MainActivity.java

package com.example.sch.headzoomlistviewdemo;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;

import java.util.ArrayList;
import java.util.List;

/**
 * Created by shichaohui on 2015/7/31 0031.
 */
public class MainActivity extends AppCompatActivity {

    private HeadZoomListView mListView;
    private View headerView;
    private List<String> datas;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initData();

        mListView = (HeadZoomListView) this.findViewById(R.id.list_view);
        headerView = LayoutInflater.from(this).inflate(R.layout.list_view_header, null);

        mListView.addHeaderView(headerView);
        mListView.setAdapter(new ArrayAdapter<>(this,
                android.R.layout.simple_expandable_list_item_1, datas));

    }

    /**
     * 初始化資料
     */
    private void initData() {
        datas = new ArrayList<>();
        for (int i = ; i < ; i++) {
            datas.add("條目  " + (i + ));
        }
    }

}
           

    接着就是我們的重頭戲自定義ListView了,首先回顧我們需要實作的效果:

  • 在頂部繼續下拉時頭部拉伸;
  • 拉伸之後手指上推減小拉伸高度;
  • 拉伸時即時更改背景圖的透明度;
  • 松手後自動彈回原位置。

    根據要實作的效果,我們可以推出需要的參數如下:

private ImageView headerImage;
private int headerImageHeight = -; // 預設高度
private int headerImageMaxHeight = -; // 最大高度
private float scaleRatio = f; // 最大拉伸比例
private int headerImageScaleHeight = -; // 被拉伸的高度
private float headerImageMinAlpha = f; // 拉伸到最高時頭部的透明度
private long durationMillis = ; // 頭部恢複動畫的執行時間
           

    由于ListView中并不能直接擷取Header,是以我們需要定義一個函數,由調用者傳入頭部的背景ImageView,并計算相關屬性:

/**
 * 設定頭部圖檔
 *
 * @param headerImage 頭部中的背景ImageView
 */
public void setHeaderImage(ImageView headerImage) {
    this.headerImage = headerImage;
    headerImageHeight = headerImage.getHeight();
    headerImageMaxHeight = (int) (headerImageHeight * scaleRatio);
    // 防止第一次拉伸的時候headerImage.getLayoutParams().height = 0
    headerImage.getLayoutParams().height = headerImageHeight;
}
           

    為了計算的準确性,我們需要在View顯示出來後才調用

setHeaderImage()

,是以需要重寫MainActivity的

onWindowFocusChanged()

方法:

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    mListView.setHeaderImage((ImageView) headerView.findViewById(R.id.iv_hander));
}
           

    為了增加擴充性,還增加以下幾個方法:

/**
 * 設定頭部的最大拉伸倍率,預設1.5f
 *
 * @param scaleRatio 頭部的最大拉伸倍率,必須大于1,小于1則預設為1.5f
 */
public void setScaleRatio(float scaleRatio) {
    this.scaleRatio = scaleRatio;
}

/**
 * 設定拉伸到最高時頭部的透明度,預設0.5f
 *
 * @param headerImageMinAlpha 拉伸到最高時頭部的透明度,0.0~1.0
 */
public void setHeaderImageMinAlpha(float headerImageMinAlpha) {
    this.headerImageMinAlpha = headerImageMinAlpha;
}

/**
 * 設定頭部恢複動畫的執行時間,預設1000毫秒
 *
 * @param durationMillis 頭部恢複動畫的執行時間,機關:毫秒
 */
public void setHeaderImageDurationMillis(long durationMillis) {
    this.durationMillis = durationMillis;
}
           

    接下來重寫ListView的

overScrollBy()

方法處理下拉/上拉過度事件:

@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY,
                                   int scrollRangeX, int scrollRangeY, int maxOverScrollX,
                                   int maxOverScrollY, boolean isTouchEvent) {
    // deltaY為拉伸過度時每毫秒拉伸的距離,正數表示向上拉伸多度,負數表示向下拉伸過度
    if (deltaY <  && headerImage.getLayoutParams().height < headerImageMaxHeight
            || deltaY >  && headerImage.getLayoutParams().height > headerImageHeight) {
        // 修改寬高
        headerImage.getLayoutParams().height -= deltaY;
        // 重新設定View的寬高
        headerImage.requestLayout();
     }
     return true;
}
           

    當ListView到達邊界并繼續拉的時候(這裡稱為”下拉/上拉過度”)就會觸發此方法,其中參數

deltaY

表示每毫秒拉動的距離,下拉時此參數是負數,上拉過度時是正數。

    是以,滿足條件

deltaY < 0 && headerImage.getLayoutParams().height < headerImageMaxHeight

(下拉且沒有到達最大高度)的時候,需要增大headerImage的寬度,但是此時

deltaY

是負數,是以使用”-=”修改高度。條件

deltaY > 0 && headerImage.getLayoutParams().height > headerImageHeight

表示上拉過度且已被拉伸的時候,需要減小headerImage的寬度,但是此時

deltaY

是正數,是以也使用”-=”修改高度。

    

headerImage.requestLayout();

會重新測量View的寬高,不調用此方法上面的修改也就不會更新到界面上。

    實作下拉一段高度後上推減小headerImage的拉伸高度效果,需要重寫

onScrollChanged()

方法,重寫

onTouchEvent()

也可以,隻是太難控制,且效果不太好:

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);

    if (headerImage == null) {
        return;
    }
    View view = (View) headerImage.getParent();
    // 上推的時候減小高度至預設高度
    if (view.getTop() <  && headerImage.getLayoutParams().height > headerImageHeight) {
        headerImage.getLayoutParams().height += view.getTop();
        // 重新計算尺寸布局
        view.layout(view.getLeft(), , view.getRight(), view.getBottom());
        headerImage.requestLayout();
    }

}
           

    

view.layout(view.getLeft(), 0, view.getRight(), view.getBottom());

周遊視圖樹,重新測量并設定頭部的高度和子布局的位置。

    重新測量的時候,View使用

requestLayout()

方法,ViewGroup使用

layout()

方法,

layout()

方法中的四個參數前兩個表示ViewGroup左上角坐标和右下角坐标。

    接着實作松手時的動畫:

@Override
public boolean onTouchEvent(MotionEvent ev) {

    switch (ev.getAction()) {
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            if (headerImage.getLayoutParams().height > headerImageHeight) {  
                // 使用動畫恢複預設高度  
                headerImage.clearAnimation();  
                headerImage.startAnimation(new ResetAnimaton());  
                return true;  
            }
    }

    return super.onTouchEvent(ev);
}
/**
 * 自定義恢複時的動畫
 */
class ResetAnimaton extends Animation {

    public ResetAnimaton() {
        setDuration(durationMillis);
        // 計算開始動畫時的拉伸高度
        headerImageScaleHeight = headerImage.getLayoutParams().height - headerImageHeight;
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        // interpolatedTime從動畫開始到結束,由0.0~1.0
        if (headerImage.getLayoutParams().height - headerImageHeight > ) {
            // 計算新高度
            headerImage.getLayoutParams().height -= headerImageScaleHeight * interpolatedTime;
            // 計算新拉伸高度
            headerImageScaleHeight -= headerImageScaleHeight * interpolatedTime;
            // 重新布局
            headerImage.requestLayout();
        }
    }
}
           

    最後加入拉伸時透明度的變化:

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);

    ...

    updateHeaderAlpha();

}

/**
 * 更新頭部的透明度
 */
private void updateHeaderAlpha() {
    // 目前拉伸高度
    int scallHeight = headerImage.getLayoutParams().height - headerImageHeight;
    if (scallHeight > ) {
        // 新的透明度(1 - 目前拉伸高度 / 最大拉伸高度 * (1 - 目标透明度))
        headerImage.setAlpha( - (float) scallHeight
                / (headerImageMaxHeight - headerImageHeight) * ( - headerImageMinAlpha));
    }
}
           

貼完整代碼

MainActivity.java

package com.example.sch.headzoomlistviewdemo;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;

import java.util.ArrayList;
import java.util.List;

/**
 * Created by shichaohui on 2015/7/31 0031.
 */
public class MainActivity extends AppCompatActivity {

    private HeadZoomListView mListView;
    private View headerView;
    private List<String> datas;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initData();

        mListView = (HeadZoomListView) this.findViewById(R.id.list_view);
        headerView = LayoutInflater.from(this).inflate(R.layout.list_view_header, null);

        mListView.addHeaderView(headerView);
        mListView.setScaleRatio(f);
        mListView.setHeaderImageDurationMillis();
        mListView.setHeaderImageMinAlpha(f);
        mListView.setAdapter(new ArrayAdapter<>(this,
                android.R.layout.simple_expandable_list_item_1, datas));

    }

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
        mListView.setHeaderImage((ImageView) headerView.findViewById(R.id.iv_hander));
    }

    /**
     * 初始化資料
     */
    private void initData() {
        datas = new ArrayList<>();
        for (int i = ; i < ; i++) {
            datas.add("條目  " + (i + ));
        }
    }

}
           

HeadZoomListView.java

package com.example.sch.headzoomlistviewdemo;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import android.widget.ImageView;
import android.widget.ListView;

/**
 * 下拉頭部縮放ListView
 * <br/>
 * Created by shichaohui on 2015/7/31 0031.
 */
public class HeadZoomListView extends ListView {

    private ImageView headerImage;
    private int headerImageHeight = -; // 預設高度
    private int headerImageMaxHeight = -; // 最大高度
    private int headerImageScaleHeight = -; // 被拉伸的高度
    private float scaleRatio = f; // 最大拉伸比例
    private float headerImageMinAlpha = f; // 拉伸到最高時頭部的透明度
    private long durationMillis = ; // 頭部恢複動畫的執行時間

    public HeadZoomListView(Context context) {
        super(context);
    }

    public HeadZoomListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public HeadZoomListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    /**
     * 設定頭部圖檔
     *
     * @param headerImage 頭部中的背景ImageView
     */
    public void setHeaderImage(ImageView headerImage) {
        this.headerImage = headerImage;
        headerImageHeight = headerImage.getHeight();
        headerImageMaxHeight = (int) (headerImageHeight * scaleRatio);
        // 防止第一次拉伸的時候headerImage.getLayoutParams().height = 0
        headerImage.getLayoutParams().height = headerImageHeight;
    }

    /**
     * 設定頭部的最大拉伸倍率,預設1.5f
     *
     * @param scaleRatio 頭部的最大拉伸倍率,必須大于1,小于1則預設為1.5f
     */
    public void setScaleRatio(float scaleRatio) {
        this.scaleRatio = scaleRatio;
    }

    /**
     * 設定拉伸到最高時頭部的透明度,預設0.5f
     *
     * @param headerImageMinAlpha 拉伸到最高時頭部的透明度,0.0~1.0
     */
    public void setHeaderImageMinAlpha(float headerImageMinAlpha) {
        this.headerImageMinAlpha = headerImageMinAlpha;
    }

    /**
     * 設定頭部恢複動畫的執行時間,預設1000毫秒
     *
     * @param durationMillis 頭部恢複動畫的執行時間,機關:毫秒
     */
    public void setHeaderImageDurationMillis(long durationMillis) {
        this.durationMillis = durationMillis;
    }

    @Override
    protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY,
                                   int scrollRangeX, int scrollRangeY, int maxOverScrollX,
                                   int maxOverScrollY, boolean isTouchEvent) {
        // deltaY為拉伸過度時每毫秒拉伸的距離,正數表示向上拉伸多度,負數表示向下拉伸過度
        if (deltaY <  && headerImage.getLayoutParams().height < headerImageMaxHeight
                || deltaY >  && headerImage.getLayoutParams().height > headerImageHeight) {
            // 修改寬高
            headerImage.getLayoutParams().height -= deltaY;
            // 重新設定View的寬高
            headerImage.requestLayout();
        }

        return true;
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);

        if (headerImage == null) {
            return;
        }
        View view = (View) headerImage.getParent();
        // 上推的時候減小高度至預設高度
        if (view.getTop() <  && headerImage.getLayoutParams().height > headerImageHeight) {
            headerImage.getLayoutParams().height += view.getTop();
            // 重新計算尺寸布局
            view.layout(view.getLeft(), , view.getRight(), view.getBottom());
            headerImage.requestLayout();
        }

        updateHeaderAlpha();

    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {

        switch (ev.getAction()) {
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (headerImage.getLayoutParams().height > headerImageHeight) {  
                    // 使用動畫恢複預設高度  
                    headerImage.clearAnimation();  
                    headerImage.startAnimation(new ResetAnimaton());  
                    return true;  
                }
        }

        return super.onTouchEvent(ev);
    }

    /**
     * 更新頭部的透明度
     */
    private void updateHeaderAlpha() {
        // 目前拉伸高度
        int scallHeight = headerImage.getLayoutParams().height - headerImageHeight;
        if (scallHeight > ) {
            // 新的透明度(1 - 目前拉伸高度 / 最大拉伸高度 * (1 - 目标透明度))
            headerImage.setAlpha( - (float) scallHeight
                    / (headerImageMaxHeight - headerImageHeight) * ( - headerImageMinAlpha));
        }
    }

    /**
     * 自定義恢複時的動畫
     */
    class ResetAnimaton extends Animation {

        public ResetAnimaton() {
            setDuration(durationMillis);
            // 計算開始動畫時的拉伸高度
            headerImageScaleHeight = headerImage.getLayoutParams().height - headerImageHeight;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            // interpolatedTime從動畫開始到結束,由0.0~1.0
            if (headerImage.getLayoutParams().height - headerImageHeight > ) {
                // 計算新高度
                headerImage.getLayoutParams().height -= headerImageScaleHeight * interpolatedTime;
                // 計算新拉伸高度
                headerImageScaleHeight -= headerImageScaleHeight * interpolatedTime;
                // 重新布局
                headerImage.requestLayout();
            }
        }
    }

}
           

布局檔案在文章開頭有貼出,這裡就不重複了。

    END

    歡迎評論吐槽……