實現炫酷的CheckBox,就這么簡單

博文出處:實現炫酷的CheckBox,就這么簡單,歡迎大家關注我的博客,謝謝!

今天給大家帶來的是一款全新的CheckBox,是不是對系統自帶的CheckBox產生乏味感了呢,那就來看看下面的CheckBox吧!

之前在逛GitHub的時候看到一款比較新穎的CheckBox:SmoothCheckBox,它的效果預覽觸動到我了,于是趁著今天有空就試著自己寫一寫。盡管效果可能不如SmoothCheckBox那樣動感,但是基本的效果還是實現了。按照慣例,下面就貼出我寫的CheckBox的gif:

checkbox gif

gif的效果可能有點過快,在真機上運行的效果會更好一些。我們主要的思路就是利用屬性動畫來動態地畫出選中狀態以及對勾的繪制過程。看到上面的效果圖,相信大家都迫不及待地要躍躍欲試了,那就讓我們開始吧。

自定義View的第一步:自定義屬性。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="SmoothCheckBox">
        <!-- 動畫持續時間 -->
        <attr name="duration" format="integer"></attr>
        <!-- 邊框寬度 -->
        <attr name="strikeWidth" format="dimension|reference"></attr>
        <!-- 邊框顏色 -->
        <attr name="borderColor" format="color|reference"></attr>
        <!-- 選中狀態的顏色 -->
        <attr name="trimColor" format="color|reference"></attr>
        <!-- 對勾顏色 -->
        <attr name="tickColor" format="color|reference"></attr>
        <!-- 對勾寬度 -->
        <attr name="tickWidth" format="dimension|reference"></attr>
    </declare-styleable>
</resources>

我們把CheckBox取名為SmoothCheckBox(沒辦法(⊙﹏⊙),這名字挺好聽的),定義了幾個等等要用到的屬性。這一步很簡單,相信大家都熟練了。

接下來看一看onMeasure(int widthMeasureSpec, int heightMeasureSpec):

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    if (widthMode == MeasureSpec.EXACTLY) {
        mWidth = widthSize;
    } else {
        mWidth = 40;
    }

    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    if (heightMode == MeasureSpec.EXACTLY) {
        mHeight = heightSize;
    } else {
        mHeight = 40;
    }
    setMeasuredDimension(mWidth, mHeight);
    int size = Math.min(mWidth, mHeight);
    center = size / 2;
    mRadius = (int) ((size - mStrokeWidth) / 2 / 1.2f);
    startPoint.set(center * 14 / 30, center * 28 / 30);
    breakPoint.set(center * 26 / 30, center * 40 / 30);
    endPoint.set(center * 44 / 30, center * 20 / 30);

    downLength = (float) Math.sqrt(Math.pow(startPoint.x - breakPoint.x, 2f) + Math.pow(startPoint.y - breakPoint.y, 2f));
    upLength = (float) Math.sqrt(Math.pow(endPoint.x - breakPoint.x, 2f) + Math.pow(endPoint.y - breakPoint.y, 2f));
    totalLength = downLength + upLength;
}

一開始是測量了SmoothCheckBox的寬、高度,默認的寬高度隨便定義了一個,當然你們可以自己去修改和完善它。然后就是設置半徑之類的,最后的startPoint、breakPoint、endPoint分別對應著選中時對勾的三個點(至于為何是這幾個數字,那完全是經驗值);downLength就是startPoint和breakPoint的距離,而相對應的upLength就是breakPoint和endPoint的距離。即以下圖示:

Checkbox繪制示意圖

在看onDraw(Canvas canvas)之前我們先來看兩組動畫,分別是選中狀態時的動畫以及未選中狀態的動畫:

// 由未選中到選中的動畫
private void checkedAnimation() {
    animatedValue = 0f;
    tickValue = 0f;
    // 選中時底色的動畫
    mValueAnimator = ValueAnimator.ofFloat(0f, 1.2f, 1f).setDuration(2 * duration / 5);
    mValueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
    // 對勾的動畫
    mTickValueAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(3 * duration / 5);
    mTickValueAnimator.setInterpolator(new LinearInterpolator());
    mTickValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            // 得到動畫執行進度
            tickValue = (float) valueAnimator.getAnimatedValue();
            postInvalidate();
        }
    });
    mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            // 得到動畫執行進度
            animatedValue = (float) valueAnimator.getAnimatedValue();
            postInvalidate();
        }
    });
    mValueAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            //當底色的動畫完成后再開始對勾的動畫
            mTickValueAnimator.start();
            Log.i(TAG," mTickValueAnimator.start();");
        }
    });
    mValueAnimator.start();
}

// 由選中到未選中的動畫
private void uncheckedAnimation() {
    animatedValue = 0f;
    mValueAnimator = ValueAnimator.ofFloat(1f, 0f).setDuration(2 * duration / 5);
    mValueAnimator.setInterpolator(new AccelerateInterpolator());
    mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            animatedValue = (float) valueAnimator.getAnimatedValue();
            postInvalidate();
        }
    });
    mValueAnimator.start();
}

這兩組動畫在點擊SmoothCheckBox的時候會調用。相似的,都是在動畫執行中得到動畫執行的進度,再來調用postInvalidate();讓SmoothCheckBox重繪。看完這個之后就是終極大招onDraw(Canvas canvas)了:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.save();
    drawBorder(canvas);
    drawTrim(canvas);
    if (isChecked) {
        drawTick(canvas);
    }
    canvas.restore();
}

// 畫對勾
private void drawTick(Canvas canvas) {
    // 得到畫對勾的進度
    float temp = tickValue * totalLength;
    Log.i(TAG, "temp:" + temp + "downlength :" + downLength);
    //判斷是否是剛開始畫對勾的時候,即等于startPoint
    if (Float.compare(tickValue, 0f) == 0) {
        Log.i(TAG, "startPoint : " + startPoint.x + ", " + startPoint.y);
        path.reset();
        path.moveTo(startPoint.x, startPoint.y);
    }
    // 如果畫對勾的進度已經超過breakPoint的時候,即(breakPoint,endPoint]
    if (temp > downLength) {
        path.moveTo(startPoint.x, startPoint.y);
        path.lineTo(breakPoint.x, breakPoint.y);
        Log.i(TAG, "endPoint : " + endPoint.x + ", " + endPoint.y);
        path.lineTo((endPoint.x - breakPoint.x) * (temp - downLength) / upLength + breakPoint.x, (endPoint.y - breakPoint.y) * (temp - downLength) / upLength + breakPoint.y);
    } else {
        //畫對勾的進度介于startPoinit和breakPoint之間,即(startPoint,breakPoint]
        Log.i(TAG, "down x : " + (breakPoint.x - startPoint.x) * temp / downLength + ",down y: " + (breakPoint.y - startPoint.y) * temp / downLength);
        path.lineTo((breakPoint.x - startPoint.x) * temp / downLength + startPoint.x, (breakPoint.y - startPoint.y) * temp / downLength + startPoint.y);
    }
    canvas.drawPath(path, tickPaint);
}

// 畫邊框
private void drawBorder(Canvas canvas) {
    float temp;
    // 通過animatedValue讓邊框產生一個“OverShooting”的動畫
    if (animatedValue > 1f) {
        temp = animatedValue * mRadius;
    } else {
        temp = mRadius;
    }
    canvas.drawCircle(center, center, temp, borderPaint);
}

// 畫checkbox內部
private void drawTrim(Canvas canvas) {
    canvas.drawCircle(center, center, (mRadius - mStrokeWidth) * animatedValue, trimPaint);
}

onDraw(Canvas canvas)代碼中的邏輯基本都加了注釋,主要就是原理搞懂了就比較簡單了。在繪制對勾時要區分當前處于繪制對勾的哪種狀態,然后對應做處理畫出線條,剩下的就簡單了。關于SmoothCheckBox的講解到這里就差不多了。

下面就貼出SmoothCheckBox的完整代碼:

public class SmoothCheckBox extends View implements View.OnClickListener {

    // 動畫持續時間
    private long duration;
    // 邊框寬度
    private float mStrokeWidth;
    // 對勾寬度
    private float mTickWidth;
    // 內飾畫筆
    private Paint trimPaint;
    // 邊框畫筆
    private Paint borderPaint;
    // 對勾畫筆
    private Paint tickPaint;
    // 默認邊框寬度
    private float defaultStrikeWidth;
    // 默認對勾寬度
    private float defaultTickWidth;
    // 寬度
    private int mWidth;
    // 高度
    private int mHeight;
    // 邊框顏色
    private int borderColor;
    // 內飾顏色
    private int trimColor;
    // 對勾顏色
    private int tickColor;
    // 半徑
    private int mRadius;
    // 中心點
    private int center;
    // 是否是選中
    private boolean isChecked;
    //對勾向下的長度
    private float downLength;
    //對勾向上的長度
    private float upLength;
    // 對勾的總長度
    private float totalLength;
    // 監聽器
    private OnCheckedChangeListener listener;

    private ValueAnimator mValueAnimator;

    private ValueAnimator mTickValueAnimator;

    private float animatedValue;

    private float tickValue;
    // 對勾開始點
    private Point startPoint = new Point();
    // 對勾轉折點
    private Point breakPoint = new Point();
    // 對勾結束點
    private Point endPoint = new Point();

    private static final String TAG = "SmoothCheckBox";

    private static final String KEY_INSTANCE_STATE = "InstanceState";

    private Path path = new Path();

    public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
        this.listener = listener;
    }

    public SmoothCheckBox(Context context) {
        this(context, null);
    }

    public SmoothCheckBox(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SmoothCheckBox(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.SmoothCheckBox);
        duration = a.getInt(R.styleable.SmoothCheckBox_duration, 600);

        defaultStrikeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, getResources().getDisplayMetrics());
        mStrokeWidth = a.getDimension(R.styleable.SmoothCheckBox_strikeWidth, defaultStrikeWidth);
        defaultTickWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2, getResources().getDisplayMetrics());
        mTickWidth = a.getDimension(R.styleable.SmoothCheckBox_tickWidth, defaultTickWidth);
        borderColor = a.getColor(R.styleable.SmoothCheckBox_borderColor, getResources().getColor(android.R.color.darker_gray));
        trimColor = a.getColor(R.styleable.SmoothCheckBox_trimColor, getResources().getColor(android.R.color.holo_green_light));
        tickColor = a.getColor(R.styleable.SmoothCheckBox_tickColor, getResources().getColor(android.R.color.white));
        a.recycle();

        trimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        trimPaint.setStyle(Paint.Style.FILL);
        trimPaint.setColor(trimColor);

        borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        borderPaint.setStrokeWidth(mStrokeWidth);
        borderPaint.setColor(borderColor);
        borderPaint.setStyle(Paint.Style.STROKE);

        tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        tickPaint.setColor(tickColor);
        tickPaint.setStyle(Paint.Style.STROKE);
        tickPaint.setStrokeCap(Paint.Cap.ROUND);
        tickPaint.setStrokeWidth(mTickWidth);

        setOnClickListener(this);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        if (widthMode == MeasureSpec.EXACTLY) {
            mWidth = widthSize;
        } else {
            mWidth = 40;
        }

        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (heightMode == MeasureSpec.EXACTLY) {
            mHeight = heightSize;
        } else {
            mHeight = 40;
        }
        setMeasuredDimension(mWidth, mHeight);
        int size = Math.min(mWidth, mHeight);
        center = size / 2;
        mRadius = (int) ((size - mStrokeWidth) / 2 / 1.2f);
        startPoint.set(center * 14 / 30, center * 28 / 30);
        breakPoint.set(center * 26 / 30, center * 40 / 30);
        endPoint.set(center * 44 / 30, center * 20 / 30);

        downLength = (float) Math.sqrt(Math.pow(startPoint.x - breakPoint.x, 2f) + Math.pow(startPoint.y - breakPoint.y, 2f));
        upLength = (float) Math.sqrt(Math.pow(endPoint.x - breakPoint.x, 2f) + Math.pow(endPoint.y - breakPoint.y, 2f));
        totalLength = downLength + upLength;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();
        drawBorder(canvas);
        drawTrim(canvas);
        if (isChecked) {
            drawTick(canvas);
        }
        canvas.restore();
    }

    @Override
    protected Parcelable onSaveInstanceState() {
        Bundle bundle = new Bundle();
        bundle.putParcelable(KEY_INSTANCE_STATE, super.onSaveInstanceState());
        bundle.putBoolean(KEY_INSTANCE_STATE, isChecked);
        return bundle;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (state instanceof Bundle) {
            Bundle bundle = (Bundle) state;
            boolean isChecked = bundle.getBoolean(KEY_INSTANCE_STATE);
            setChecked(isChecked);
            super.onRestoreInstanceState(bundle.getParcelable(KEY_INSTANCE_STATE));
            return;
        }
        super.onRestoreInstanceState(state);
    }

    // 切換狀態
    private void toggle() {
        isChecked = !isChecked;
        if (listener != null) {
            listener.onCheckedChanged(this, isChecked);
        }
        if (isChecked) {
            checkedAnimation();
        } else {
            uncheckedAnimation();
        }
    }

    // 由未選中到選中的動畫
    private void checkedAnimation() {
        animatedValue = 0f;
        tickValue = 0f;
        mValueAnimator = ValueAnimator.ofFloat(0f, 1.2f, 1f).setDuration(2 * duration / 5);
        mValueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
        mTickValueAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(3 * duration / 5);
        mTickValueAnimator.setInterpolator(new LinearInterpolator());
        mTickValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                tickValue = (float) valueAnimator.getAnimatedValue();
                postInvalidate();
            }
        });
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                animatedValue = (float) valueAnimator.getAnimatedValue();
                postInvalidate();
            }
        });
        mValueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                mTickValueAnimator.start();
                Log.i(TAG," mTickValueAnimator.start();");
            }
        });
        mValueAnimator.start();
    }

    // 由選中到未選中的動畫
    private void uncheckedAnimation() {
        animatedValue = 0f;
        mValueAnimator = ValueAnimator.ofFloat(1f, 0f).setDuration(2 * duration / 5);
        mValueAnimator.setInterpolator(new AccelerateInterpolator());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                animatedValue = (float) valueAnimator.getAnimatedValue();
                postInvalidate();
            }
        });
        mValueAnimator.start();
    }

    // 畫對勾
    private void drawTick(Canvas canvas) {
        float temp = tickValue * totalLength;
        Log.i(TAG, "temp:" + temp + "downlength :" + downLength);
        if (Float.compare(tickValue, 0f) == 0) {
            Log.i(TAG, "startPoint : " + startPoint.x + ", " + startPoint.y);
            path.reset();
            path.moveTo(startPoint.x, startPoint.y);
        }
        if (temp > downLength) {
            path.moveTo(startPoint.x, startPoint.y);
            path.lineTo(breakPoint.x, breakPoint.y);
            Log.i(TAG, "endPoint : " + endPoint.x + ", " + endPoint.y);
            path.lineTo((endPoint.x - breakPoint.x) * (temp - downLength) / upLength + breakPoint.x, (endPoint.y - breakPoint.y) * (temp - downLength) / upLength + breakPoint.y);
        } else {
            Log.i(TAG, "down x : " + (breakPoint.x - startPoint.x) * temp / downLength + ",down y: " + (breakPoint.y - startPoint.y) * temp / downLength);
            path.lineTo((breakPoint.x - startPoint.x) * temp / downLength + startPoint.x, (breakPoint.y - startPoint.y) * temp / downLength + startPoint.y);
        }
        canvas.drawPath(path, tickPaint);
    }

    // 畫邊框
    private void drawBorder(Canvas canvas) {
        float temp;
        if (animatedValue > 1f) {
            temp = animatedValue * mRadius;
        } else {
            temp = mRadius;
        }
        canvas.drawCircle(center, center, temp, borderPaint);
    }

    // 畫checkbox內部
    private void drawTrim(Canvas canvas) {
        canvas.drawCircle(center, center, (mRadius - mStrokeWidth) * animatedValue, trimPaint);
    }

    @Override
    public void onClick(View view) {
        toggle();
    }

    /**
     * 判斷checkbox是否選中狀態
     *
     * @return
     */
    public boolean isChecked() {
        return isChecked;
    }

    /**
     * 設置checkbox的狀態
     *
     * @param isChecked 是否選中
     */
    public void setChecked(boolean isChecked) {
        this.setChecked(isChecked, false);
    }

    /**
     * 設置checkbox的狀態
     *
     * @param isChecked   是否選中
     * @param isAnimation 切換時是否有動畫
     */
    public void setChecked(boolean isChecked, boolean isAnimation) {
        this.isChecked = isChecked;
        if (isAnimation) {
            if (isChecked) {
                checkedAnimation();
            } else {
                uncheckedAnimation();
            }
        } else {
            animatedValue = isChecked ? 1f : 0f;
            tickValue = 1f;
            invalidate();
        }
        if (listener != null) {
            listener.onCheckedChanged(this, isChecked);
        }
    }

    public interface OnCheckedChangeListener {
        void onCheckedChanged(SmoothCheckBox smoothCheckBox, boolean isChecked);
    }
}

下面是SmoothCheckBox的源碼下載,如果有問題可以在下面留言來交流:

SmoothCheckBox.rar

GitHub : SmoothCheckBox

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容