首先,不得不说苹果手机无论硬件还是软件的设计都做到了极致,然后模仿者趋之若鹜,作为统治智能手机市场半壁江山的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文件,使用起来相当繁琐,很难做成公共控件多个项目使用。
当然啦,笔者这里就简单实现下核心功能,代码的封装就由各位开发者自己去完善啦!