【视图控件篇】自定义Android控件之IOS滑动开关模拟详解

       首先,不得不说苹果手机无论硬件还是软件的设计都做到了极致,然后模仿者趋之若鹜,作为统治智能手机市场半壁江山的Android系统,自然也不例外。

       目前来说,小米定制的MIUI里面开关就是模拟的这种(左图),但是笔者个人感觉体验一般,没有做到完美。真正做得好的是QQ浏览器(右图)

         
             

        所以今天,我们来尝试写一个类似的Android控件。

        说到Android自定义控件,无非是三种:独立控件、复合控件、定制化控件。

        独立控件:直接继承自View或者ViewGroup,然后自己定制measure、layout、draw,一般情况难度较大,比如Android自带的TextView、ImageView等。

        复合控件:继承自已实现的布局控件,通过多个视图组合后添加控制逻辑而成的控件,比如知名的下拉刷新开源库PullToRefresh等。

            定制化控件:直接继承已实现的控件,复写某些方法做一些特殊化定制,这种比较常见,也最简单,不多赘述。

            好,我们先来分析下用哪种方式来实现呢?

       分析下将要实现控件的交互特性:
       1、控件外观的背景由两个半圆+一个矩形构成,中间有一个白色圆球来回滚动。
       2、点击时,白色圆球移动至另一侧,能看到一段移动的动画,同时背景底色渐变。
       3、控件表面手指快速滑动时(Fling手势),白色圆球移动,效果同2。
       4、手指按住白色圆球,在控件上来回滑动,圆球跟着手指移动,但不会超出控件边界,同时背景底色渐变。如果手指松开,白色圆球自动移动至最近的一侧,同样能看到移动动画和背景底色渐变。

       我们先来试想一下用独立控件来设计这种功能。首先需要重写onMeasure方法,固定控件尺寸;其次需要在onDraw里面画两边是半圆中间矩形的背景区域,和中间移动的白色小球,难度不大;接下来写交互,由于手势比较复杂,涉及到点击、快速滑动,缓慢滑动等复杂手势,View自身的touchEvent很难去处理,这时就需要借助GestureDetector手势类去处理,难度也还行;最后白色小球移动的动画效果只能借助invalidate去刷新draw,这时头就真正大了!
      
       然后,关于定制化控件,这个不需要想就能放弃,因为SDK就没有类似的控件能让我们直接修改。
       
       最后,我们来分析下复合控件。控件两头半圆中间矩形的样式,通过一个Layout设置圆角shape背景图就能搞定,中间的小球,直接用一个View设置成圆形白色背景。关于手势,同样是用GestureDetector处理。最后难点的动画效果,可以用ObjectAnimator属性动画控制View搞定。这个方案看起来相当靠谱,那我们就不妨来实现一下。

      控件可继承自LinnearLayout、FrameLayout或者RelativeLayout等基本布局控件,都可行,这里笔者采用的是LinnearLayout,控件命名成SwitcherView,就是开关控件的意思,接下来我们来看一下具体代码:

package com.example.test;

import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.GradientDrawable;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.Checkable;
import android.widget.LinearLayout;

public class SwitcherView extends LinearLayout implements Checkable, AnimatorUpdateListener{

	// 开关状态对应的控件背景底色:这里开关打开用的蓝色、关闭用的灰色
	private static final int COLOR_CHECK = 0xff479FF4;
	private static final int COLOR_UNCHECK = 0xffDDDEDF;
	
	// 开关状态标识位
	private boolean mIsChecked;
	
	private OnCheckedChangeListener mOnCheckedChangeListener;
	
	// 开关状态背景色Drawable
	private GradientDrawable mGradientDrawable;
	
	// 中间白色小球
	private View mBallView;
	
	// 手势交互类
	private GestureDetector mGestureDetector;
	
	// 小球移动动画:属性动画
	private ObjectAnimator mBallMoveAnimator;
	
	// 小球移动最大范围
	private int mMaxBallMoveDistance;

	// 是否是小球移动状态
	private boolean mMoveState;
	
	private boolean mDelay;
	
	public SwitcherView(Context context, AttributeSet attrs, int defStyleAttr) {
		super(context, attrs, defStyleAttr);
		init(context);
	}

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

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

	private void init(Context context){
		
		// 初始化底色背景
		mGradientDrawable = (GradientDrawable) context.getResources().getDrawable(R.drawable.bg_switcher);
		mGradientDrawable.setColor(COLOR_UNCHECK);
		setBackground(mGradientDrawable);
		
		// 手势类初始化
		mGestureDetector = new GestureDetector(getContext(), new CheckOnGestureListener());
		
		// 由于findViewById时,视图边界未初始化,导致开关状态失效,所以需要在Layout时设置开关初始状态。
		getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
			
			@Override
			public void onGlobalLayout() {
				if(mIsChecked && mDelay){
					mIsChecked = !mIsChecked;
					toggle();
				}
				mDelay = false;
				getViewTreeObserver().removeOnGlobalLayoutListener(this);
			}
		});
	}
	
	@Override
	protected void onFinishInflate() {
		// 初始化白色小球
		mBallView = findViewById(R.id.ball_switcher);
		mBallMoveAnimator = ObjectAnimator.ofFloat(mBallView, "translationX", 0, 0);
		mBallMoveAnimator.addUpdateListener(this);
		super.onFinishInflate();
	}
	
	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 计算小球的滚动范围
		int width = getMeasuredWidth();
		if(mBallView != null){
			int ballRadius = mBallView.getMeasuredWidth() / 2;
			mMaxBallMoveDistance = width - getPaddingLeft() - getPaddingRight() - ballRadius * 2;
		}
		
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
	}
	
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		// 当手指触摸小球来回移动,手指松开时需要计算消失移动路径和开关状态
		if(mMoveState && (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP)){
			mMoveState = false;
			float ballX = (Float)mBallMoveAnimator.getAnimatedValue();
			// 小球移向最左,表示开关关闭
			if(ballX < mMaxBallMoveDistance / 2){
				// 开关是否被切换
				if(mIsChecked){
					toggle();
				}else{
					startAnimation(0, true);
				}
			}
			// 小球移向最右,表示开关打开
			if(ballX >= mMaxBallMoveDistance / 2){
				if(!mIsChecked){
					toggle();
				}else{
					startAnimation(mMaxBallMoveDistance, true);
				}
			}
		}
		
		// 触摸手势交给手势类控制
		return mGestureDetector.onTouchEvent(event);
	}
	
	@Override
	public void setChecked(boolean checked) {
		if(checked == mIsChecked){
			return;
		}
		toggle();
	}

	@Override
	public boolean isChecked() {
		return mIsChecked;
	}

	@Override
	public void toggle() {
		mIsChecked = !mIsChecked;
		
		if(mMaxBallMoveDistance <= 0){
			mDelay = true;
			return;
		}
		
		// 启动开关切换时 小球移动动画
		startAnimation(mIsChecked ? mMaxBallMoveDistance: 0, true);
		
		// 回调状态
		if(mOnCheckedChangeListener != null){
			mOnCheckedChangeListener.onCheckedChanged(mIsChecked);
		}
	}

	public void startAnimation(float finalX, boolean force){
		if(mBallMoveAnimator.isRunning()){
			if(force){
				mBallMoveAnimator.cancel();
			}else{
				return;
			}
		}
		
		float startX = (Float)mBallMoveAnimator.getAnimatedValue();
		
		mBallMoveAnimator.setFloatValues(startX, finalX);
		
		float distance = Math.abs(startX- finalX);
		
		// 小球移动动画持续时间需要计算,这里是移动1px需要5ms
		mBallMoveAnimator.setDuration((int)(distance * 5));
		mBallMoveAnimator.start();
	}
	

	@Override
	public void onAnimationUpdate(ValueAnimator animation) {
		// 小球移动式,空间背景底色需要变化,在这里计算RGB
		int delaRed = Color.red(COLOR_UNCHECK) - Color.red(COLOR_CHECK);
		int delaGreen = Color.green(COLOR_UNCHECK) - Color.green(COLOR_CHECK);
		int delaBlue = Color.blue(COLOR_UNCHECK) - Color.blue(COLOR_CHECK);
		float rate = (Float)mBallMoveAnimator.getAnimatedValue() / mMaxBallMoveDistance;
		
		int newColor = Color.rgb(Color.red(COLOR_UNCHECK) - (int)(delaRed * rate), 
				Color.green(COLOR_UNCHECK) - (int)(delaGreen * rate), 
				Color.blue(COLOR_UNCHECK) - (int)(delaBlue * rate));
		mGradientDrawable.setColor(newColor);
	}
	
	/**
     * Register a callback to be invoked when the checked state of this button
     * changes.
     *
     * @param listener the callback to call on checked state change
     */
    public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
        mOnCheckedChangeListener = listener;
    }
    
    /**
     * Interface definition for a callback to be invoked when the checked state
     * of a compound button changed.
     */
    public static interface OnCheckedChangeListener {
        /**
         * Called when the checked state of a compound button has changed.
         *
         * @param isChecked  The new checked state of buttonView.
         */
        void onCheckedChanged(boolean isChecked);
    }

    private class CheckOnGestureListener extends SimpleOnGestureListener{
    	
    	@Override
    	public boolean onDown(MotionEvent e) {
    		return true;
    	}
    	
    	@Override
    	public boolean onSingleTapUp(MotionEvent e) {
    		// 点击控件切换开关
    		toggle();
    		return super.onSingleTapUp(e);
    	}
    	
    	@Override
    	public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    		// 手指拉着小球移动
    		float finalX = (Float) mBallMoveAnimator.getAnimatedValue() - distanceX * 2;
    		if(finalX < 0){
    			finalX = 0;
    		}
    		if(finalX > mMaxBallMoveDistance){
    			finalX = mMaxBallMoveDistance;
    		}
    		if(finalX != (Float) mBallMoveAnimator.getAnimatedValue()){
    			startAnimation(finalX, false);
    			mMoveState = true;
    		}
    		return super.onScroll(e1, e2, distanceX, distanceY);
    	}
    	
    	@Override
    	public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
    			float velocityY) {
    		// 快速滑动切换开关
    		if(velocityX < 0 && mIsChecked){
    			toggle();
    		}
    		if(velocityX > 0 && !mIsChecked){
    			toggle();
    		}
    		return super.onFling(e1, e2, velocityX, velocityY);
    	}
    }
}


另外,依赖于两个drawable文件,一并奉上:
1、 bg_switcher.xml  控件背景
</pre>     <pre name="code" class="html"><?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

    <corners android:radius="25dip" />

</shape>

2、 ball_switcher.xml  白色小圆球背景
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval" >

    <solid android:color="@android:color/white"/>
</shape>


关于控件使用,需要定义一个layout:
switcher_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<com.example.test.SwitcherView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="50dip"
    android:layout_height="25dip"
    android:gravity="center_vertical"
    android:paddingLeft="1dip"
    android:paddingRight="1dip" >

    <View
        android:id="@+id/ball_switcher"
        android:layout_width="23dip"
        android:layout_height="23dip"
        android:background="@drawable/ball_switcher" />

</com.example.test.SwitcherView>

直接在layout布局文件里面include就可以啦
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.test.MainActivity" >

    <include
        android:id="@+id/switcher"
        android:layout_width="50dip"
        android:layout_height="25dip"
        layout="@layout/switcher_layout" />

</RelativeLayout>

我们来看看效果图吧


总结:控件的基本特性和功能以及实现完成,但是评价一个控件写得好不好,光是完成功能还是远远不够的。
              还应该考虑到扩展性和使用性。
              比如说开关底色需要用橙色或者红色怎么办?现在代码里写死蓝色的,扩展性明显不好。
              再者,一个控件需要依赖多个xml文件,使用起来相当繁琐,很难做成公共控件多个项目使用。
              当然啦,笔者这里就简单实现下核心功能,代码的封装就由各位开发者自己去完善啦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值