常规&高级UI编程 课堂笔记 | 青训营笔记

335 阅读8分钟

这是我参与「第四届青训营 」笔记创作活动的第4天

常规&高级UI编程 课堂笔记

课程回顾

安卓四大组件

  1. 界面组件:Activity & Fragment
  2. 服务组件:Service
  3. 广播组件:Broadcast
  4. 数据组件:ContentProvider & ContentResolver

课程大纲

课堂上结合大量代码和示例逐步从单个UI组件基础到多个UI组件排版、从静态页面绘制到动态页面的设计、从系统组件应用到自定义组件等多个维度、由浅及深的阐述常规&高级UI编程相关知识。具体将分为以下几个部分:

  • UI组件:学习Android UI组件相关知识
  • 布局:学习如何将多个UI组件排版成想要的界面
  • 渲染:学习Android UI渲染流程及原理
  • 交互:学习Android常规的交互知识及原理
  • 动画:学习Android动画相关知识
  • 自定义View:学习如何自定义View

UI组件

什么是Android UI?

  • UI:User Interface
  • Android系统是图形用户界面操作系统
  • UI界面由多个不同功能的UI组件构成
  • Android SDK提供了大量的UI组件

常规UI组件

常规UI组件大多由 Android Framework 中的 android.widght 这个 package 提供(比如 android.widght.TextView)

组件Java Class
文本组件TextView
图片组件ImageView
按钮组件Button
输入框组件EditText
复选框组件CheckBox
单选按钮RadioButton

常规View的属性和方法

常规View的属性和方法.png

高级UI组件

常规UI组件大多是 View,高级UI组件大多是ViewGroup,比常规UI组件由更多的功能

组件Java Class
滑动组件ScrollView
列表组件ListView/RecyclerView
下拉刷新组件PullToRefresh
分页组件ViewPager
布局组件LinearLayout/RealtiveLayout/...

View和ViewGroup的关系

View和ViewGroup的关系.png

部分View的继承关系

部分View的继承关系.png

将 UI 整合成一个页面,需要考虑三个方面:大小,位置,层级

LinearLayout

LinearLayout 特定属性

属性功能描述
android:orientation布局内组件的排列方向
android:layout_weight布局内组件大小权重
android:divider布局内组件间分割线
android:showDividers布局内组件间分割线位置
android:dividerPadding布局内分割线padding
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:paddingLeft="16dp"
     android:paddingRight="16dp"
     android:orientation="vertical" > 
 ​
     <EditText        
         android:layout_width="match_parent"        
         android:layout_height="wrap_content" />   
 ​
     <EditText        
         android:layout_width="match_parent"      
         android:layout_height="wrap_content" />   
 ​
     <EditText       
         android:layout_width="match_parent"     
         android:layout_height="0dp"     
         android:layout_weight="1"   // 权重最高,最后全分给这个组件   
         android:gravity="top" />   
 ​
     <Button      
         android:layout_width="100dp"       
         android:layout_height="wrap_content"     
         android:layout_gravity="right" /> // 按钮在右
 </LinearLayout>

RelativeLayout

 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"    android:layout_height="match_parent"
     android:paddingLeft="16dp"    android:paddingRight="16dp" >
 ​
     <EditText
         android:id="@+id/name"        
         android:layout_width="match_parent"   
         android:layout_height="wrap_content" />  
 ​
      <Spinner        
         android:id="@+id/dates"        
         android:layout_width="0dp"    android:layout_height="wrap_content"  
         android:layout_below="@id/name"    
         android:layout_alignParentLeft="true"       
         android:layout_toLeftOf="@+id/times" />    
 ​
     <Spinner        
         android:id="@id/times"        
         android:layout_width="96dp"    android:layout_height="wrap_content"   
         android:layout_below="@id/name" 
         android:layout_alignParentRight="true" />    
 ​
     <Button        
         android:layout_width="96dp"    android:layout_height="wrap_content"   
         android:layout_below="@id/times" 
         android:layout_alignParentRight="true"  />
 </RelativeLayout>

FrameLayout

FrameLayout示例

 <FrameLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:background="@android:color/white">
 ​
     <TextView
         android:layout_width="300dp"
         android:layout_height="300dp"
         android:gravity="center"
         android:background="@android:color/holo_blue_bright"
         android:text="我是第一层"/>
 ​
     <TextView
         android:layout_width="150dp"
         android:layout_height="140dp"
         android:gravity="center"
         android:background="@android:color/holo_green_light"
         android:text="我是第二层"/>
 ​
 </FrameLayout>

ConstraintLayout

ConstraintLayout示例

 <android.support.constraint.ConstraintLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
 ​
     <ImageView
         android:id="@+id/iv_beauty"
         android:layout_width="100dp"
         android:layout_height="200dp"
         android:scaleType="centerCrop"
         android:src="@drawable/beauty"
         app:layout_constraintLeft_toLeftOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 ​
     <ImageView
         android:id="@+id/iv_girl"
         android:layout_width="100dp"
         android:layout_height="0dp"
         android:scaleType="centerCrop"
         android:src="@drawable/girl"
         app:layout_constraintBottom_toBottomOf="@+id/iv_beauty" // 底部对齐
         app:layout_constraintLeft_toRightOf="@+id/iv_beauty" // 左边贴着上个组件的右边
         app:layout_constraintTop_toTopOf="parent" /> // 顶部对齐
 </android.support.constraint.ConstraintLayout>

布局总结

常用布局总结.png

渲染

布局页面渲染步骤:布局加载、布局解析、UI 渲染

布局加载

在Activity中设置布局文件

 TextView textView;
 ​
 @Override
 public void onCreate(Bundle savedInstanceState) {  
     // call the super class onCreate to complete the creation of activity like    
     // the view hierarchy    
     super.onCreate(savedInstanceState);  
   
      // set the user interface layout for this activity  
      // the layout file is defined in the project res/layout/main_activity.xml file
     setContentView(R.layout.main_activity);   
 ​
      // initialize member TextView so we can manipulate it later   
     textView = (TextView) findViewById(R.id.text_view);
 }

注册 Manifest

设置布局文件

setContentView究竟做了什么?通过源码分析可知,setContentView最终创建了DecorView,并由LayoutInflater来加载了XML文件

 @Override
 public void setContentView(int resId) {
     ensureSubDecor();
     ViewGroup contentParent = (ViewGroup) mSubDecor
     .findViewById(android.R.id.content);
     contentParent.removeAllViews();
     LayoutInflater.from(mContext).inflate(resId, contentParent);
     mOriginalWindowCallback.onContentChanged();
 }

布局解析

布局加载后调用了 LayoutInflater 相关方法,那 LayoutInflater 究竟做了什么?通过源码分析可知,LayoutInflater 解析了XML文件,并根据XML文件生成了View实例,并将View实例添加了到了其 ViewGroup 中

 void rInflate(XmlPullParser parser, View parent, Context context,
         AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
     while (((type = parser.next()) != XmlPullParser.END_TAG||
             parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
             // 省略
             // 核心代码
             final View view = createViewFromTag(parent, name, context, attrs);
             final ViewGroup viewGroup = (ViewGroup) parent;
             final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
             rInflateChildren(parser, view, attrs, true);
             viewGroup.addView(view, params);
     }
 }

XML 中的 View 是如何生成实例的?

根据 XML 中 View 类名来找到相应 View,并将XML中的描述属性解析为 AttributeSet,并作为第二个参数传给了 View 构造器

布局渲染

页面绘制起点

Activity 在 onResume() 之后才显示

View 绘制流程

View绘制流程.png

页面绘制流程

页面绘制流程.png

Choreographer 编舞者

VSync 信号

VSync信号.png

UI渲染

UI渲染.png

渲染总结

渲染总结.png

UI渲染流程

requestLayout() 触发绘制

measure 测量宽度,有根据内容决定展示多大

leyout 绘制起始点

draw 具体绘制

交互

获取 View 实例

findViewById

迭代查找

知道 group 可以

常用交互事件监听器

触摸事件

当用户触摸屏幕时,系统将建立一系列的MotionEvent对象,MotionEvent包含关于发生触摸的位置和时间等细节信息,MotionEvent对象被传递到相应的捕获函数中,例如onTouchEvent()。

屏幕触发事件处理流程.png

捕获触摸事件

  • Activity和View都有onTouchEvent(),用于处理触摸事件。
  • 当用户触摸屏幕时,会回调触摸视图上的onTouchEvent()。 对于最终被识别为手势的每个轻触事件序列,onTouchEvent() 都会多次被触发。
     public class MainActivity extends Activity {
         @Override
         public boolean onTouchEvent(MotionEvent event){
             int action = MotionEventCompat.getActionMasked(event);
             switch(action) {
                 case (MotionEvent.ACTION_DOWN) :
                     Log.d(DEBUG_TAG,"Action was DOWN");
                     return true;
                 case (MotionEvent.ACTION_MOVE) :
                     Log.d(DEBUG_TAG,"Action was MOVE");
                     return true;
                 case (MotionEvent.ACTION_UP) :
                     Log.d(DEBUG_TAG,"Action was UP");
                     return true;
                 case (MotionEvent.ACTION_CANCEL) :
                     Log.d(DEBUG_TAG,"Action was CANCEL");
                     return true;
                 case (MotionEvent.ACTION_OUTSIDE) :
                     Log.d(DEBUG_TAG,"Movement occurred outside bounds " + "of current screen element");
                     return true;
                 default :
                     return super.onTouchEvent(event);
         }
     }

触摸事件分发

boolean dispatchTouchEvent(MotionEvent ev)

事件处理流程

事件处理流程.png

事件响应

ACTION_UP (抬起时)里响应 Click

判断 onLongClick 是否执行,未执行则为点击

longClick 和 Click 不会同时执行,原理上互斥

交互总结

交互总结.png

动画

帧动画

为什么不用 gif 而用帧动画

因为帧动画播发时序可控

早期美团刷新动画就是用的帧动画播放

帧动画.png

帧动画示例:

 <?xml version="1.0" encoding="utf-8"?> 
 <animation-list xmlns:android="http://schemas.android.com/apk/res/android" 
     android:oneshot="false">
     <item android:drawable="@drawable/ic_wifi_0" android:duration="100"/>
     <item android:drawable="@drawable/ic_wifi_1" android:duration="100"/>
     <item android:drawable="@drawable/ic_wifi_2" android:duration="100"/>
     <item android:drawable="@drawable/ic_wifi_3" android:duration="100"/> 
     <item android:drawable="@drawable/ic_wifi_4" android:duration="100"/> 
     <item android:drawable="@drawable/ic_wifi_5" android:duration="100"/> 
 </animation-list>
 ​
 ​
 private void playAnimation() {
     mImageView.setImageResource(R.drawable.frame_anim); 
     AnimationDrawable animationDrawable = (AnimationDrawable) mImageView.getDrawable(); 
     animationDrawable.start(); 
     ...
     animationDrawable.stop(); 
 }

补间动画

补间动画.png

补间动画示例:

 public void tweenedAnimation(View view) {   
     // 创建一个透明度动画,透明度从1渐变至0
     AlphaAnimation alphaAnimation = new AlphaAnimation(1, 0);  
     alphaAnimation.setDuration(3000);    
 ​
     // 创建一个旋转动画,从0度旋转至360度
     RotateAnimation rotateAnimation = new RotateAnimation(0, 360, 
         Animation.RELATIVE_TO_SELF, 0.5f,Animation.RELATIVE_TO_SELF, 0.5f);  
     rotateAnimation.setDuration(3000);    
 ​
     ScaleAnimation scaleAnimation = new ScaleAnimation(1, 0.5f, 1, 0.5f,
         Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
     scaleAnimation.setDuration(3000);    
 ​
     TranslateAnimation translateAnimation = new TranslateAnimation(
         Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 1, 
         Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 1);    
     translateAnimation.setDuration(3000);   
 ​
     // 组合上述4种动画
     AnimationSet animationSet = new AnimationSet(true);    
     animationSet.addAnimation(alphaAnimation);    
     animationSet.addAnimation(rotateAnimation);   
     animationSet.addAnimation(scaleAnimation);    
     animationSet.addAnimation(translateAnimation);   
     view.startAnimation(animationSet);
 }

动画播放是否为匀速,可通过选用插值器来设置

插值器示例:

系统提供的插值器

系统提供的插值器.png

View 动画小结

只能作用与 View ,不可作用与其他对象

只能改变 View 的视觉效果,无法改变属性

动画效果单一:帧动画只能实现类似 GIF 的动画效果、补间动画只能实现透明度、旋转、缩放和平移四种动画效果

View动画小结.png

属性动画

属性动画.png

属性动画示例:

 private void startObjectAnimatorSet() {
 ​
      // 创建一个ObjectAnimator,将mImageView的scaleX属性值从1变化到0.5
     Animator scaleXAnimator = ObjectAnimator.ofFloat(mImageView, "scaleX", 1, 0.5f); 
     scaleXAnimator.setDuration(2000);  
 ​
      // 创建一个ObjectAnimator,将mImageView的scaleY属性值从1变化到0.5
     Animator scaleYAnimator = ObjectAnimator.ofFloat(mImageView, "scaleY", 1, 0.5f); 
     scaleYAnimator.setDuration(2000);  
 ​
      // 创建一个ObjectAnimator,将mImageView的rotationX属性值从0变化到360
     Animator rotationXAnimator = ObjectAnimator.ofFloat(mImageView, "rotationX", 0, 360);
     rotationXAnimator.setDuration(2000); 
 ​
      // 创建一个ObjectAnimator,将mImageView的rotationY属性值从0变化到360
     Animator rotationYAnimator = ObjectAnimator.ofFloat(mImageView, "rotationY", 0, 360);
     rotationYAnimator.setDuration(2000); 
 ​
     // 组合上述4种动画
     AnimatorSet animatorSet = new AnimatorSet(); 
     animatorSet.play(scaleXAnimator).with(scaleYAnimator)
     .before(rotationXAnimator).after(rotationYAnimator); 
     animatorSet.start(); 
 }

动画总结

两类动画的根本区别在于:是否改变动画本身的属性

  • 视图动画:不改变动画的属性,在动画过程中仅对图像进行变换来达到动画效果。无论动画结果在哪,该View的位置和响应区域都是在原地,不会根据结果而移动;
  • 属性动画:改变了动画属性 因属性动画在动画过程中对动态改变了对象属性,从而达到了动画效果

自定义View

自定义View示例

创建View

 public class SwitchButton extends View implements Checkable { // 所有 review 都继承自 View or ViewGroup
 ​
     public SwitchButton(Context context) {
         super(context);
         init(context, null);
     }
 ​
     public SwitchButton(Context context, AttributeSet attrs) { // 构造器的参数顺序最好不改
         super(context, attrs);
         init(context, attrs);
     }
 ​
     public SwitchButton(Context context, AttributeSet attrs, int defStyleAttr) {
         super(context, attrs, defStyleAttr);
         init(context, attrs);
     }
 ​
     @TargetApi(Build.VERSION_CODES.LOLLIPOP)
     public SwitchButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
         super(context, attrs, defStyleAttr, defStyleRes);
         init(context, attrs);
     }

处理 View 布局

 @Override
 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
     final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
     final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
     if(widthMode == MeasureSpec.UNSPECIFIED || widthMode == MeasureSpec.AT_MOST){
         widthMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_WIDTH, MeasureSpec.EXACTLY);
     }
     if(heightMode == MeasureSpec.UNSPECIFIED || heightMode == MeasureSpec.AT_MOST){
         heightMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_HEIGHT, MeasureSpec.EXACTLY);
     }
     super.onMeasure(widthMeasureSpec, heightMeasureSpec);
 }
 ​
 @Override
 protected void onSizeChanged(int w, int h, int oldw, int oldh) {
     float viewPadding = Math.max(shadowRadius + shadowOffset, borderWidth);
     height = h - viewPadding - viewPadding;
     width = w - viewPadding - viewPadding;
     ...
 }

绘制 View

 @Override
 protected void onDraw(Canvas canvas) {
     super.onDraw(canvas);
     //绘制白色背景的圆角矩形
     paint.setStrokeWidth(borderWidth);
     paint.setStyle(Paint.Style.FILL);
     paint.setColor(background);
     drawRoundRect(canvas, left, top, right, bottom, viewRadius, paint);
 ​
     //绘制关闭状态的边框
     paint.setStyle(Paint.Style.STROKE);
     paint.setColor(uncheckColor);
     drawRoundRect(canvas,left, top, right, bottom, viewRadius, paint);
     ...
     //绘制按钮左边绿色长条遮挡
     paint.setStyle(Paint.Style.FILL);
     paint.setStrokeWidth(1);
     drawArc(canvas, left, top, left + 2 * viewRadius, top + 2 * viewRadius,90, 180, paint);
     canvas.drawRect( left + viewRadius, top,viewState.buttonX,
     top + 2 * viewRadius,paint);
     ...
     //绘制按钮
     drawButton(canvas, viewState.buttonX, centerY);
 }

处理用户交互

 @Override
 public boolean onTouchEvent(MotionEvent event) {
     if(!isEnabled()) { return false; }
     switch (actionMasked){
         case MotionEvent.ACTION_DOWN:
            ...
             break;
         case MotionEvent.ACTION_MOVE:
             if(isPendingDragState()){ //在准备进入拖动状态过程中,可以拖动按钮位置
                 ...
             }else if(isDragState()){ //拖动按钮位置,同时改变对应的背景颜色
                ...
             }
             break;
         case MotionEvent.ACTION_UP:
             if(System.currentTimeMillis() - touchDownTime <= 300){ //点击时间小于300ms,认为是点击操作
                 toggle();
             }else if(isDragState()){ //在拖动状态,计算按钮位置,设置是否切换状态
                 ...
             }
             break;
         case MotionEvent.ACTION_CANCEL:
             removeCallbacks(postPendingDrag);
             break;
     }
     return true;
 }

处理动画

 // 初始化View时设置动画
 valueAnimator = ValueAnimator.ofFloat(0f, 1f); 
 valueAnimator.setDuration(effectDuration); 
 valueAnimator.setRepeatCount(0);
 valueAnimator.addUpdateListener(animatorUpdateListener);
 // 点击开关后启动动画
 valueAnimator.start();
 ​
 // 简单动画更新回调,触发View绘制
 private ValueAnimator.AnimatorUpdateListener animatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
     @Override
     public void onAnimationUpdate(ValueAnimator animation) {
         float value = (Float) animation.getAnimatedValue();
         switch (animateState) {
             ...
             case ANIMATE_STATE_SWITCH:
                 viewState.buttonX = beforeState.buttonX + (afterState.buttonX - beforeState.buttonX) * value;
                 float fraction = (viewState.buttonX - buttonMinX) / (buttonMaxX - buttonMinX);
                 viewState.checkStateColor = (int) argbEvaluator.evaluate( fraction,uncheckColor,checkedColor);
                 viewState.radius = fraction * viewRadius;
                 viewState.checkedLineColor = (int) argbEvaluator.evaluate(fraction,Color.TRANSPARENT, checkLineColor);
                 break;
         }
         postInvalidate();
     }
 };

自定义View小结

自定义View小结.png

课程总结

课程总结.png

QA

UI 不好看,用的系统的组件的话,可能是属性设置的不太对,可以找找实例