0%

笔算法

已知两条直线的方程:

$$A_1x+B_1y+C_1=0$$

$$A_2x+B_2y+C_2=0$$

大家都知道的求交点方法,消元:

$$A_1B_2x+B_1B_2y+B_2C_1=0$$

$$A_2B_1x+B_1B_2y+B_1C_2=0$$

然后两式相减:

$$(A_1B_2-A_2B_1)x=B_2C_1-B_1C_2$$

即可得到交点的x坐标:

$$x=\frac{(A_1B_2-A_2B_1)}{B_2C_1-B_1C_2}$$

然后带入方程1:

$$A_1[\frac{A_1B_2-A_2B_1}{B_2C_1-B_1C_2}]+B_1y+C_1=0$$

化简得到:

$$y=\frac{C_1}{B_1}-\frac{A_1}{B_1}\frac{A_1B_2-A_2B_1}{B_2C_1-B_1C_2}$$

如果按照以上方法用程序实现,也没什么问题,简单粗暴,但有没有更简单的方法呢,有!

程序实现

直线方程推导

先来看另外一个问题,已知一条直线过两点$P_1(x_1,y_1)$和$P_2(x_2,y_2)$,求该直线方程

设直线方程$Ax+By-C=0$

很容易可以推导出:

$$A=y_2-y_1$$

$$B=x_1-x_2$$

$$C=Ax_1+By_1$$

直线交点公式

设两条直线的方程为:

$$A_1x+B_1y-C_1=0$$

$$A_2x+B_2y-C_2=0$$

程序实现如下:

1
2
3
4
5
6
7
val det = A1*B2 - A2*B1
if (det == 0) {
//两条直线平行
} else {
val x = (B2*C1-B1*C2)/det
val y = (A1*C2-A2*C1)/det
}

这个公式是怎么得出来的呢,给方程1乘以$B_2$,给方程2乘以$B_1$,也就是使用上述消元法可得到:

$$A_1B_2x+B_1B_2y=C_1B_2$$

$$A_2B_1x+B_1B_2y=C_2B_1$$

两式相减:

$$(A_1B_2-A_2B_1)x=C_1B_2-C_2B_1$$

可以看到x的乘数就是上边的det变量,而等式右边的算式就是下边的x计算式的分子

同理,y的计算方法也是如此:

$$A_1A_2x+A_2B_1y=C_1A_2$$

$$A_1A_2x+A_1B_2y=C_2A_1$$

两式相减:

$$(A_1B_2-A_2B_1)y=C_2A_1-C_1A_2$$

Flutter

Flutter是Google推出的仅需一次开发,即可多平台发布原生App的UI套件集。(移动端、web端、桌面端)
Flutter具有以下特性:

  • 快速开发
  • 丰富灵活的UI
  • 原生

安装Flutter

  • 下载
    可以选择:

1.从官网下载安装包;
2.使用git命令下载
这里选择使用git命令下载,在E盘根目录,右键点击空白处在弹出的菜单中选择git bash here,输入命令:

1
$ git clone https://github.com/flutter/flutter.git

然后执行命令。

  • 添加环境变量
    E:\flutter\bin;添加到Path变量中。
  • 执行环境检查
    打开cmd,输入flutter doctor并执行,flutter将会检查环境配置的情况,例如:
    1
    2
    3
    4
    5
    [-] Android toolchain - develop for Android devices
    • Android SDK at D:\Android\sdk
    ✗ Android SDK is missing command line tools; download from https://goo.gl/XxQghQ
    • Try re-installing or updating your Android SDK,
    visit https://flutter.dev/setup/#android-setup for detailed instructions.
  • 下载Android SDK
  • 下载Android Studio,打开并添加插件Flutter
  • 准备测试设备,如添加一个虚拟机

运行Demo

选择新建Flutter项目,等自动生成了demo项目,启动虚拟机,再点击右上角的运行按钮。启动完可以看到如下画面:

修改代码后,无论是按Ctrl+S保存,还是按热重启(hot reload),flutter都会无缝快速重启app

写第一个APP

Flutter的开发语言是DartDart这门语言吸收了现代语言的一些优质特性,所以熟悉面向对象开发的人能很快上手。这里不过多的对Dart语法进行说明,只是跟随官方文档书写第一个App。

本例将要实现一个随机生成名称的列表,并且列表将无限生成,效果图如下:

  • 新建Flutter项目
    项目名称建议为小写字母加下划线分割单词,例如flutter_demo

  • 删除多余的自动生成的代码
    项目新建完成IDE会自动生成一些代码,除了MyApp是主题框架,它下方的代码都是我们不需要的,所以手动删除,并修改代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import 'package:flutter/material.dart';

    void main() => runApp(MyApp());

    class MyApp extends StatelessWidget {
    @Override
    Widget build(BuildContext context) {
    return MaterialApp(
    title: 'Welcome to Flutter',
    home: Scaffold(
    appBar: AppBar(
    title: Text('Welcome to Flutter'),
    ),
    body: Center(
    child: Text('Hello World'),
    ),
    ),
    );
    }
    }

    运行(保存)可以看到如图效果:

  • 使用名称生成库
    随机名称的生成需要用到一个库english_words,打开pubsec.yaml文件,找到dependencies并在其下方添加english_words,如下:

    1
    2
    3
    4
    5
    dependencies:
    flutter:
    sdk: flutter
    cupertino_icons: ^0.1.2
    english_words: ^3.1.0

    保存并点击右上角的Packaget Get,然后将自动下载库,可以看到控制台输出:

    1
    2
    3
    $ flutter pub get
    Running "flutter pub get" in startup_namer...
    Process finished with exit code 0

    main.dart中引入库:

    1
    2
    import 'package:flutter/material.dart';
    import 'package:english_words/english_words.dart';

修改上述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyApp extends StatelessWidget {
@Override
Widget build(BuildContext context) {
final wordPair = WordPair.random();
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(
title: Text('Welcome to Flutter'),
),
body: Center(
child: Text(wordPair.asPascalCase),
),
),
);
}
}

重新运行可以看到随机生成的名称:

  • 添加Stateful Widget

    Stateless Widgets 是不可变的,它们的属性都不可变。
    Stateful Widgets 维持了可在控件生命周期内可变的状态。实现一个Stateful Widget需要至少两个类:1)StatefulWidget类; 2)State类。StatefulWidget类自身可变,但是State类维持整个控件的生命周期不变。

添加State类:

1
2
3
class RandomWordsState extends State<RandomWords> {

}

添加StatefulWidget类:

1
2
3
4
class RandomWords extends StatefulWidget {
@Override
RandomWordsState createState() => RandomWordsState();
}

然后给RandomWordsState补上build方法:

1
2
3
4
5
6
7
class RandomWordsState extends State<RandomWords> {
@Override
Widget build(BuildContext context) {
final wordPair = WordPair.random();
return Text(wordPair.asPascalCase);
}
}

还要替换MyApp中的代码:

1
body:Center(child:RandomWords(),),

重新运行,看到的效果与上一张图相同。

  • 做一个无限滑动的列表
    RandomWordsState中添加一个数组用于保存生成的名称,并添加一个大号的字体:
    1
    2
    3
    4
    class RandomWordsState extends State<RandomWords> {
    final _suggestions = <WordPair>[];
    final _biggerFont = const TextStyle(fontSize: 18.0);
    ...
    添加**_buildSuggestions**方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Widget _buildSuggestions() {
    return ListView.builder(
    padding: const EdgeInsets.all(16.0),
    itemBuilder: (context, i) {
    if (i.isOdd) return Divider();

    final index = i ~/ 2;
    if (index >= _suggestions.length) {
    _suggestions.addAll(generateWordPairs().take(10));
    }
    return _buildRow(_suggestions[index]);
    });
    }
    添加**_buildRow**方法:
    1
    2
    3
    4
    5
    6
    7
    8
    Widget _buildRow(WordPair pair) {
    return ListTile(
    title: Text(
    pair.asPascalCase,
    style: _biggerFont,
    ),
    );
    }
    修改RandomWordsStatebuild方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    Widget build(BuildContext context) {
    return Scaffold(
    appBar: AppBar(
    title: Text('Startup Name Generator'),
    ),
    body: _buildSuggestions(),
    );
    }
    修改MyAppbuild方法:
    1
    2
    3
    4
    5
    6
    @Override build(BuildContext context){
    return MaterialApp(
    title: 'Startup Name Generator',
    home: RandomWords(),
    );
    }

重新运行App,可以看到预期的效果:

CoordinatorLayout(协调布局)是将嵌套滑动的泛用性发挥到极致的控件,只需要通过简单配置它的子View的Behavior(行为)就可以实现复杂而炫丽的效果。

Behavior

Behavior是CoordinatorLayout的内部静态类,通过继承Behavior可以实现自定义的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public static abstract class Behavior<V extends View> {
public Behavior() {
}

public Behavior(Context context, AttributeSet attrs) {
}

...

public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent ev) {
return false;
}

public boolean onTouchEvent(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent) {
return false;
}

public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency) {
return false;
}

/**
* @return true 这个Behavior改变了child view的大小或位置; false 则没有
*/
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency) {
return false;
}

public void onDependentViewRemoved(@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull View dependency) {
}

/**
* @return true 由此Behavior测量此View; false 父级CoordinatorLayout测量
*/
public boolean onMeasureChild(@NonNull CoordinatorLayout parent, @NonNull V child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
return false;
}

/**
* @return true 由此Behavior布局此View; false 父级CoordinatorLayout布局
*/
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
return false;
}

/**
* @return true 此Behavior接收嵌套滑动事件
*/
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View directTargetChild, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type) {
if(type == ViewCompat.TYPE_TOUCH) {
return onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes);
}
return false;
}

public void onNestedScrollAccepted(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View directTargetChild, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type) {
if(type == ViewCompat.TYPE_TOUCH) {
onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, axes);
}
}

public void onStopNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {

}

public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type, @NonNull int[] consumed) {
consumed[0] += dxUnconsumed;
consumed[1] += dyUnconsumed;
onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type)
}

public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, @NestedScrollType int type) {
if(type == ViewCompat.TYPE_TOUCH) {
onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
}
}
}

以上源码摘录了Behavior常用的方法,从简单到复杂的需求皆可实现。简单需求可以搭配layoutDependsOnonDependentViewChanged,复杂需求可自行定制嵌套滑动的细节。

Behavior的绑定方式

通过布局属性绑定

即在布局文件中通过app:layout_behavior添加,看看源码是怎么解析的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
if (TextUtils.isEmpty(name)) {
return null;
}

final String fullName;
if (name.startsWith(".")) {
//相对于app的包,在前面添加app包名
fullName = context.getPackageName() + name;
} else if (name.indexOf('.') >= 0) {
//完整包名
fullName = name;
} else {
//WIDGET_PACKAGE_NAME = pkg != null ? pkg.getName() : null
fullName = !TextUtils.isEmpty(WIDGET_PACKAGE_NAME) ? (WIDGET_PACKAGE_NAME + '.' + name) : name;
}

try {
Map<String, Constructor<Behavior>> constructors = sConstructors.get();
if (constructors == null) {
constructors = new HashMap<>();
sConstructors.set(constructors);
}
Constructor<Behavior> c = constructors.get(fullName);
if (c == null) {
final Class<Behavior> clazz = (Class<Behavior>) Class.forName(fullName, false, context.getClassLoader());
c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
c.setAccessible(true);
constructors.put(fullName, c);
}
return c.newInstance(context, attrs);
} catch (Exception e) {
throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
}
}

可以看到,在布局文件中通过填写的全名或者半名自动补全查找,然后通过反射实例化对应的Behavior类。接下来再来看看,这个方法在哪里调用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class LayoutParams extends MarginLayoutParams {
...
LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
...
mBehaviorResolved = a.hasValue(R.styleable.CoordinatorLayout_Layout_layout_behavior);
if (mBehaviorResolved) {
mBehavior = parseBehavior(context, attrs, a.getString(R.styleable.CoordinatorLayout_Layout_layout_behavior));
}
a.recycle();

if (mBehavior != null) {
//解析完成并绑定成功后,调用回调方法通知
mBehavior.onAttachedToLayoutParams(this);
}
}
}

可以看到,Behavior是在LayoutParams初始化时解析并绑定的。

绑定默认行为

旧版本的默认行为是在自定义的类上添加注解**@DefaultBehavior(),而新版本则要求自定义类实现AttachedBehavior接口,并通过getBehavior()**方法返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
LayoutParams getResolvedLayoutParams(View child) {
final LayoutParams result = (LayoutParams) child.getLayoutParams();
if (!result.mBehaviorResolved) {
//就是这里,看到了吧
if (child instanceof AttachedBehavior) {
Behavior attachedBehavior = ((AttachedBehavior) child).getBehavior();
if (attachedBehavior == null) {
Log.e(TAG, "Attached behavior class is null");
}
result.setBehavior(attachedBehavior);
result.mBehaviorResolved = true;
} else {
//旧版通过注解查找Behavior class的方式
Class<?> childClass = child.getClass();
DefaultBehavior defaultBehavior = null;
while (childClass != null
&& (defaultBehavior = childClass.getAnnotation(DefaultBehavior.class))
== null) {
childClass = childClass.getSuperclass();
}
if (defaultBehavior != null) {
try {
result.setBehavior(
defaultBehavior.value().getDeclaredConstructor().newInstance());
} catch (Exception e) {
Log.e(TAG, "Default behavior class " + defaultBehavior.value().getName()
+ "could not be instantiated. Did you forget"
+ " a default constructor?", e);
}
}
result.mBehaviorResolved = true;
}
}
return result;
}

Behavior与行为交互

CoordinatorLayout的本质还是通过Behavior将嵌套滑动“发散”出去,“分给”一个或多个子View同步享用。要知道它是怎么做到的,只要看看它其中的与嵌套滑动相关的方法即可,例如onStartNestedScroll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
@SuppressWarnings("unchecked")
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
boolean handled = false;

final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
if (view.getVisibility() == View.GONE) {
//如果它消失了,就不分发消息
continue;
}
final LayoutParams lp = (LayoutParams) view.getLayoutParams();
final Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
//它会尝试从child的LayoutParams中获取Behavior
//并询问Behavior是否接收嵌套滑动事件
//如果接收,后续的事件就会发送给对应的Behavior
final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child, target, axes, type);
handled |= accepted;
lp.setNestedScrollAccepted(type, accepted);
} else {
lp.setNestedScrollAccepted(type, false);
}
}
return handled;
}

onNestedScroll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
@SuppressWarnings("unchecked")
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed) {
final int childCount = getChildCount();
boolean accepted = false;
int xConsumed = 0;
int yConsumed = 0;

for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
if (view.getVisibility() == GONE) {
continue;
}

final LayoutParams lp = (LayoutParams) view.getLayoutParams();
if (!lp.isNestedScrollAccepted(type)) {
continue;
}

final Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
mBehaviorConsumed[0] = 0;
mBehaviorConsumed[1] = 0;

viewBehavior.onNestedScroll(this, view, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, mBehaviorConsumed);

xConsumed = dxUnconsumed > 0 ? Math.max(xConsumed, mBehaviorConsumed[0]) : Math.min(xConsumed, mBehaviorConsumed[0]);
yConsumed = dyUnconsumed > 0 ? Math.max(yConsumed, mBehaviorConsumed[1]) : Math.min(yConsumed, mBehaviorConsumed[1]);

accepted = true;
}
}

consumed[0] += xConsumed;
consumed[1] += yConsumed;

if (accepted) {
onChildViewChanged(EVENT_NESTED_SCROLL);
}
}

onNestedScroll方法中可以明显的看到,如果有其中任意一个Child的Behavior接收并消费了事件,那么就会在方法结束位置调用onChildViewChanged方法,onChildViewChanged顾名思义——有View发生了改变,很容易就可以联想到一定会触发某个Behavior的onDependentViewChanged方法(如果有View依赖这个消费事件的View的情况下)。

为了印证这种想法,就需要查看onChildViewChanged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@SuppressWarnings("unchecked")
final void onChildViewChanged(@DispatchChangeEvent final int type) {
final int layoutDirection = ViewCompat.getLayoutDirection(this);
final int childCount = mDependencySortedChildren.size();
final Rect inset = acquireTempRect();
final Rect drawRect = acquireTempRect();
final Rect lastDrawRect = acquireTempRect();

for (int i = 0; i < childCout; i++) {
final View child = mDependencySortedChildren.get(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) {
continue;
}

//...省略一部分关于重定位的代码
for (int j = i + 1; j < childCount; j++) {
final View checkChild = mDependencySortedChildren.get(j);
final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
final Behavior b = checkLp.getBehavior();

if (b != null && b.layoutDependsOn(this, checkChild, child)) {
if(type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
checkLp.resetChangedAfterNestedScroll();
continue;
}

final boolean handled;
switch (type) {
case EVENT_VIEW_REMOVED:
b.onDependentViewRemoved(this, childChild, child);
handled = true;
break;
default:
//果然如此,这就是我们要找的
handled = b.onDependentViewChanged(this, checkChild, child);
break;
}

if (type == EVENT_NESTED_SCROLL) {
checkLp.setChangedAfterNestedScroll(handled);
}
}
}
}
releaseTempRect(inset);
releaseTempRect(drawRect);
releaseTempRect(lastDrawRect);
}

果不其然,在倒数几行的位置找到了handled = b.onDependentViewChanged(this, checkChild, child),整个流程就已经连起来了。

CoordinatorLayout

还记得刚才说Behavior绑定的时候找到的getResolvedLayoutParams吗,那么它是在哪里调用的呢,继续找,找到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void prepareChildren() {
mDependencySortedChildren.clear();
mChildDag.clear();//这是一个有向无环图(DirectedAcyclicGraph<View>)
//也就是树

for (int i = 0, count = getChildCount(); i < count; i++) {
final View view = getChildAt(i);

//解析并绑定Behavior
final LayoutParams lp = getResolvedLayoutParams(view);
lp.findAnchorView(this, view);

mChildDag.addNode(view);

for (int j = 0; j < count; j++) {
if (j == i) {
continue;
}
final View other = getChildAt(j);
if (lp.dependsOn(this, view, other)) {
if (!mChildDag.contains(other)) {
mChildDag.addNode(other);
}
mChildDag.addEdge(other, view);
}
}
}

//将通过深度优先搜索得到的排序过后的结果添加到mDependencySortedChildren中
mDependencySortedChildren.addAll(mChildDag.getSortedList());
Collections.reverse(mDependencySortedChildren);
}

然后,在onMeasure方法中调用,准备好所有的子节点,然后再开始测量,接着再进行布局。对于所有节点,都会尝试优先查找节点的LayoutParams是否包含Behavior,如果包含,再询问Behavior是否接管测量或者布局(即behavior.onMeasureChildbehavior.onLayoutChild)。

Nested Scroll

当一个可滑动ViewGroup内部包含另一个可滑动View,用户触摸内部可滑动View时,应当首先滑动内部View,内部View滑不动时外部ViewGroup应接着进行滑动

以上描述的是用户预期的体验,但是事实上,这种效果违背了一个事件只有一个View进行消费的约定,因此按照常规的Touch事件分发机制这种效果是很难实现的。所以需要一套全新的滑动机制——Nested Scroll(嵌套滑动)

嵌套滑动是由内部View发起,可由内部View和外部View共同消费一个事件的机制。当检测到一个滑动事件的时候,先由内部View开启嵌套滑动,交由外部View预先进行滑动距离消费,没有消费完的由内部View进行消费,如果内部View还是没有消费完,再交由外部View消费,如此反复,直至消费完为止。消费顺序即parent->child->parent->child->...->child

嵌套滑动是在5.0之后添加到View和ViewGroup中给几乎所有的View增加了支持的一系列代码,如使用频率极高的RecyclerViewNestedScrollView等。而与此同时,Google将其中的主要方法和辅助工具类提炼了出来,以供开发者们在自定义View时使用。

NestedScrollingChild(省略了Fling相关的方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface NestedScrollingChild {
void setNestedScrollingEnabled(boolean enabled);
boolean isNestedScrollingEnabled();
boolean startNestedScroll(@ScrollAxis int axes);
void stopNestedScroll();
boolean hasNestedScrollingParent();
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed,
@Nullable int[] offsetInWindow);
boolean dispatchNestedPreScroll(int dx, int dy,
@Nullable int[] consumed, @Nullable int[] offsetInWindow)
......
}

NestedScrollingParent(省略了Fling相关的方法)

1
2
3
4
5
6
7
8
9
public interface NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onStopNestedScroll(@NonNullView target);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
......
int getNestedScrollAxes();
}

第二版接口相比于第一版,主要区别是,其中一些方法添加了一个滑动类型参数(触摸、惯性滑动)

NestedScrollingChild2

1
2
3
4
5
6
7
public interface NestedScrollingChild2 extends NestedScrollingChild {
boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);
void stopNestedScroll(@NestedScrollType int type);
boolean hasNestedScrollingParent(@NestedScrollType int type);
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
}

NestedScrollingParent2

1
2
3
4
5
6
7
public interface NestedScrollingParent2  extends NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);
void onStopNestedScroll(@NonNullView target, @NestedScrollType int type);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, @NestedScrollType int type);
}

第三版接口再一次添加了一个记录已消耗的滑动距离的参数

NestedScrollingChild3

1
2
3
public interface NestedScrollingChild3 extends NestedScrollingChild2 {
void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type, @NonNull int[] consumed);
}

NestedScrollingParent3

1
2
3
public interface NestedScrollingParent3 extends NestedScrollingParent2 {
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type, @NonNull int[] consumed);
}

在自定义View时建议使用第三版的接口和辅助工具类(NestedScrollingParentHelper、NestedScrollingChildHelper)。

窗口是Activity的容器

窗口(Window)接收用户的点击事件并逐步向内层传递,即Window->Activity->ViewGroup->...->View

dispatchTouchEvent、onInterceptTouchEvent与onTouchEvent

  • dispatchTouchEvent负责事件分发,即事件由外向内传递事件的方法
  • onInterceptTouchEvent是交给ViewGroup判断是否决定需要拦截事件(不让它的子View获取事件)的方法
  • onTouchEvent是View(或ViewGroup)自身消费事件的方法

MotionEvent

** MotionEvent**是对Touch事件的封装,MotionEvent主要包含以下事件类型:

  • ACTION_DOWN(手指按下)
  • ACTION_MOVE (手指在屏幕上移动)
  • ACTION_UP (手指离开屏幕)
  • ACTION_CANCEL (特殊事件,事件被取消,通常为被父类拦截)

dispatchTouchEvent伪源码(简化版源码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean handled = false;

//在ACTION_DOWN时如果子类决定不处理事件
//(即child.dispatchTouchEvent返回false)
//那么后续事件将不再继续向内传递
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus();

if (!isDisallowIntercept && onInterceptTouchEvent(ev)) {
isSelfNeedEvent = true;
handled = onTouchEvent(ev);
} else {
handled = child.dipatchTouchEvent(ev);
if (handled) isChildNeedEvent = true;

if (!handled) {
handled = onTouchEvent(ev);
if (handled) isSelfNeedEvent = true;
}
}
} else {
if (isSelfNeedEvent) {
handled = onTouchEvent(ev);
} else {
if (!isDisallowIntercept && onInterceptTouchEvent(ev)) {
isSelfNeedEvent = true;

boolean cancel = MotionEvent.obtain(ev);
cancel.action = MotionEvent.ACTION_CANCEL;
handled = child.dispatchTouchEvent(cancel);
cancel.recycle();
} else {
handled = child.dispatchTouchEvent(ev);
}
}
}

if(ev.actionMasked == MotionEvent.ACTION_UP
|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {
clearStatus();
}

return handled;
}

isDisallowIntercept是ViewGroup的一个成员变量,在子类调用父类的requestDisallowInterceptTouchEvent方法时会改变它的值。

onInterceptTouchEvent和requestDisallowInterceptTouchEvent

requestDisallowInterceptTouchEvent是子类在必要的时候用来告诉父类要不要拦截事件的方法。并且onInterceptTouchEvent会逐层向外传递。

前言

轮式选择器,顾名思义,就是像滚轮一样的选择器。在老式的Android系统上,曾经有过轮式选择器样式的DatePicker(如下图)。

优点是系统封装好的,直接调用即可,缺点就是样式不好定制(例如我想改选中线的颜色长度,改选中文字的颜色字号大小什么的)。即使他提供有相应的修改接口,定制起来也是相当的麻烦。我也想过通过自定义View的方式来实现,但是苦于一直没办法做出无限循环的效果,另外即使自定义View,也同样会有自定义样式麻烦的问题。

是否没有办法了呢?

当然不是!直到我遇到Google的天才程序员们设计出的一个全新的控件[RecyclerView](https://developer.android.com/guide/topics/ui/layout/recyclerview 需要全球互联网)。它能代替几乎所有的适配器型容器控件(ListView, GridView等),更能更自由的定制子元素样式(通过自定义ItemDecoration),它使得列表的布局方式定制更容易,仅仅需要自定义LayoutManager即可。

有请主角LayoutManager登场

LayoutManager是RecyclerView最重要的合作伙伴,通过切换LayoutManager,可以瞬间从ListView变成GridView,或者变成其他样子。正因为这样的高度可变性才使得实现真正好用的轮式选择器成为了可能。废话不多说,开始吧。

WheelLayoutManager

首先我还是要感谢 陈小缘 写的这篇文章 《看完让你直呼666的自定义LayoutManager之旅》 为我实现WheelLayoutManager提供了思路。

自定义LayoutManager与自定义ViewGroup有许多共通之处,基本流程也是一致的:测量、布局、刷新(滚动)。

最终效果

需求分析

不过首先,我们还是要来做一个需求分析,对于轮式选择器,需要实现以下功能:

  1. 第一个元素垂直居中于控件,其余的按顺序往后排列
  2. 居中的元素高亮效果
  3. 居中的元素旁边添加一些装饰元素
  4. 滑动到某两个元素中间时,自动滚动到某个元素居中(即选中效果)
  5. 无限循环

垂直居中

这个其实很简单,后续的功能都要以此为基础。因为要考虑滑动的问题,所以需要设置一个变量scrollOffsetY来记录当前的偏移值。当scrollOffsetY = 0的时候,第一个元素垂直居中于控件。我们考虑设置一个基本参数visibleCount,即轮式选择器当中可显示的元素个数,假定为5。

如图,假定每个元素的高度为一致的140px,整个控件的高度为5 x 140 = 700 px,那么第一个元素的顶部坐标即为 2 x 140 = 280 px,此时定为scrollOffsetY = 0。(且不考虑无限循环模式)

这里需要算出一个元素的布局位置计算公式(顶部),在布局时带入当前的偏移值和元素的index即可算出特定的元素的布局位置。将前边所说的数值考虑进来,当scrollOffsetY=0时index为0的元素位置为280px,index为1的元素位置为420px,……由此可以类推出如下的通用公式:

1
2
3
4
5
6
7
8
/**
* 获取指定Item在特定scrollOffsetY值时的顶部位置
*
* @param index Item在适配器中的Index值
*/
private fun getLayoutTop(index: Int): Int {
return index * requiredItemHeight + requiredMarginTop - scrollOffsetY
}

其中:

  • requiredItemHeight:元素高度(140px)
  • requiredMarginTop:第一个元素居中所需要的距离(280px)

重要的全局参数

说到这几个全局参数,不得不把剩下的几个参数也在这里全部说明:

  • visibleCount:可见的元素个数,作为构造函数参数传进来的,必须为大于等于3的奇数,否则就没有任何意义(暂不考虑其他情况)
  • requiredSpaceCount:第一个元素垂直居中所需要补充的空白元素的个数,这里可见元素为5,因此这里需要的数量为2,至于计算公式:requiredSpaceCount = (visibleCount - 1) /2
  • requiredItemHeight:这里我们要求每个元素的高度相同,当我们只限定RecyclerView的高度的时候,就需要计算这个值:requiredItemHeight = height / visibleCount
  • requiredMarginTop:第一个元素垂直居中需要补充的空白元素的总高度,这里需要补充2个空白元素,所以总高度为280px,计算公式:requiredMarginTop = requiredItemHeight * requiredSpaceCount

布局所有元素

说完了重要参数,布局位置计算公式也有了,那么接下来就该布局代码上场了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
if (state.itemCount == 0) {
removeAndRecycleAllViews(recycler)
return
}
calculateParams()
//暂时分离和回收全部有效的Item
detachAndScrapAttachedViews(recycler)

if (state.itemCount > ((visibleCount + 1) / 2)) {
canScrollVertically = true
}
layoutChildren(recycler, state)
}

这里的**layoutChildren()**方法不止在这里用得到,这是关键的方法,但是本质上也不复杂。在布局之前一定要通过detachAndScrapAttachedViews(recycler)暂时分离和回收全部有效元素,然后再布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun layoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
val (headIndex, tailIndex) = getLayoutRange(state.itemCount)
posLayoutHead = if (headIndex >= 0) headIndex else 0
posLayoutTail = if (tailIndex >= 0) tailIndex else 0
if (headIndex >= 0 && tailIndex >= 0) {
for (i in (headIndex..tailIndex)) {
val child = getItemView(recycler, i)
val decoratedWidth = getDecoratedMeasuredWidth(child)
val childTop = getLayoutTop(i)
val childBottom = childTop + getDecoratedMeasuredHeight(child)
layoutDecorated(child, 0, childTop, decoratedWidth, childBottom)
}
}

val removalList = ArrayList<RecyclerView.ViewHolder>()
removalList.addAll(recycler.scrapList)
removalList.forEach { holder ->
removeView(holder.itemView)
recycler.recycleView(holder.itemView)
}
}

根据scrollOffsetY计算出布局的范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private fun getLayoutRange(itemCount: Int): Pair<Int, Int> {
var headIndex = -1
var tailIndex = -1
for (i in (0 until itemCount)) {
val childTop = getLayoutTop(i)
val childBottom = childTop + requiredItemHeight
if (isLayoutInVisibleArea(childTop, childBottom)) {
headIndex = i
break
}
}
for (i in (headIndex + 1 until itemCount)) {
val childTop = getLayoutTop(i)
if (childTop > height) {
tailIndex = i - 1
break
}
}
if (tailIndex < 0) {
tailIndex = itemCount - 1
}
return headIndex to tailIndex
}

布局完成后,从recycler的“废品列表”(scrapList)中拿出要回收的View,全部移除和回收掉。还记得刚才的detachAndScrapAttachedViews(recycler)吗?所有分离和回收的都会暂时放到这里的scrapList中,而布局时获取View也是优先从这里取出“原料”,因此,布局完成后彻底回收时,从scrapList中找出所有的View移除和回收即可。至此,布局所有元素的任务便完成了。

让元素动起来

当你在屏幕上放上你的手指,然后拖动时,会触发回调方法scrollVerticallyBy,在这个方法中,需要更新偏移值,然后重新布局元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
//<0 :手指往下滑 >0 :手指往上滑
if (state.itemCount == 0) {
return 0
}
calculateParams()
detachAndScrapAttachedViews(recycler)

val lastOffset = scrollOffsetY
updateScrollOffsetY(dy, lastOffset, state.itemCount)
//重新布局
layoutChildren(recycler, state)
return if (lastOffset == scrollOffsetY) 0 else dy
}

你是否注意到,LinearLayoutManager滑动到边界(第一个元素或最后一个元素)时有个回弹效果,不用担心,回弹效果不需要我们来实现,我们只需要计算好最大偏移值和最小偏移值即可。因为要实现垂直居中功能,前边我们计算了第一个元素垂直居中时,第一个元素距离顶部的位置(280px),那如果滑动到最后一个元素垂直居中时呢?也是一样的道理,最后一个元素的下边就需要2个元素的“补白”(也是280px),这样的效果需要设置一个溢出值:

1
2
3
4
5
6
7
8
9
10
11
private fun updateScrollOffsetY(dy: Int, lastOffsetY: Int, itemCount: Int) {
scrollOffsetY += dy
val childrenHeight = itemCount * requiredItemHeight

val maxOverflowHeight = childrenHeight - requiredItemHeight
if (scrollOffsetY < 0) {
scrollOffsetY = 0
} else if (scrollOffsetY > maxOverflowHeight) {
scrollOffsetY = if (maxOverflowHeight > 0) maxOverflowHeight else lastOffsetY
}
}

往下滑自然不必多说,当滑到小于0的位置时,强制把偏移值设置为0,那么滑动到顶部就不能继续再往下滑了。往上滑滑动最后一个元素居中时,根据前边算出的顶部距离公式(getLayoutTop),带入最后一个元素的Index值,这里设scrollOffsetY为x:

$$ (itemCount - 1) * requiredItemHeight + requiredMarginTop - x = requiredMarginTop $$

计算出最大偏移值:

$$ x = itemCount * requiredItemHeight -requiredItemHeight $$

重新布局元素,直接再调用**layoutChildren(recycler, state)**即可,没有任何区别。

选中效果,居中元素装饰,居中元素高亮

之所以我要把这3个功能放到一起,因为元素装饰和元素高亮是以选中效果为基础的。怎么做选中效果呢,根据我们的设计,只有当scrollOffsetY的值为一些特定的值的时候(280px,420px,560px,……),元素的布局才会呈现给我们一种居中选中的特殊视觉效果。

所以滑动到某个偏移值的时候,可以计算出这个值最接近的某元素居中所需要的偏移值,然后通过动画,滑动到这个特定偏移值。

选中效果

1
2
3
4
5
6
7
8
9
10
11
12
private fun findClosestItemPosition(): Int {
var estimatedPosition = -1
var minDistance = Int.MAX_VALUE
for (i in (0 until itemCount)) {
val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY)
if (distance < minDistance) {
minDistance = distance
estimatedPosition = i
}
}
return estimatedPosition
}
1
2
3
4
5
6
/**
* 获取指定position的Item选中时对应的ScrollOffset值
*/
private fun getRequiredScrollOffset(targetPosition: Int): Int {
return targetPosition * requiredItemHeight
}

滑动停止时调用,然后生成动画并执行:

1
2
3
4
5
6
7
8
9
10
11
12
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
when (state) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
draggingStartListener?.invoke()
stopScrollAnimation()
}
RecyclerView.SCROLL_STATE_IDLE -> {
startScrollAnimation(findClosestItemPosition(), itemCount)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private fun startScrollAnimation(position: Int, itemCount: Int) {
if (position < 0) {
return
}
stopScrollAnimation()
val scrollDistance = (getRequiredScrollOffset(position) - scrollOffsetY)
//既然定位都是Int,那动画值的变化也要用Int,用Float就会出现滑动停止后偏离几个像素的尴尬场面
scrollValueAnimator = ValueAnimator.ofInt(0, scrollDistance).setDuration(300)
scrollValueAnimator.addUpdateListener(ScrollAnimatorUpdateListener { deltaValue ->
updateScrollOffsetY(deltaValue, scrollOffsetY, itemCount)
requestLayout()
})
scrollValueAnimator.addListener(object : AnimatorListenerProxy() {
override fun onAnimationEnd(animation: Animator) {
selectedPosition = position
}
})
scrollValueAnimator.start()
}

private class ScrollAnimatorUpdateListener(val valueUpdated: (Int) -> Unit) :
ValueAnimator.AnimatorUpdateListener {
private var lastValue: Int = 0
override fun onAnimationUpdate(animation: ValueAnimator) {
val currentValue = animation.animatedValue as Int
if (currentValue != 0) {
valueUpdated(currentValue - lastValue)
}
lastValue = currentValue
}
}

居中元素高亮

本质上还是依托于选中效果,还记得前边我们用一个全局变量selectedPosition记录了选中的元素吗,同时暴露了一个回调方法selectionChangedListener给外界,在外界调用更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
yearLayoutManager = WheelLayoutManager(5).apply {
selectionChangedListener = this@MainActivity::onYearSelectionChanged
draggingStartListener = {
deselectIndex(yearAdapter)
}
}

......

private fun onYearSelectionChanged(position: Int) {
val selectedValue = bindModel.yearAdapter?.getValue(position) ?: -1
if (selectedValue > 0) {
selectedYear = selectedValue
onDayChanged()
//选中元素,导致元素的文字颜色等发生变化
selectIndex(bindModel.yearAdapter, position)
bindModel.yearDisplay = selectedYear
}
}

这里我使用了DataBinding来实现高亮效果,不过多描述,建议看源码(源码地址在文末):

1
2
3
4
5
6
7
8
9
10
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/int_to_str(model.value)}"
android:textColor="@{model.selected ? @color/hex_db262e : @color/hex_9c9c9c}"
android:textSize="@dimen/px_53"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

居中元素装饰

比如这里做的日历选择器,要添加背景颜色,高亮区域背景颜色,以及其他装饰性图案,都需要一个坐标,在LayoutManager中将计算好的相关参数暴露给外界即可。然后自定义ItemDecoration来实现:

1
2
3
4
5
6
7
/**
* 居中的item的上边距
*/
val selectionTop: Int
get() = requiredMarginTop
val itemHeight: Int
get() = requiredItemHeight
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//DateItemDecoration
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
//取得WheelLayoutManager
val layoutManager = parent.layoutManager as? WheelLayoutManager ?: return
val highlightLineTop = layoutManager.selectionTop + 0f
val highlightLineBottom = highlightLineTop + layoutManager.itemHeight + 0f

val parentWidth = parent.width + 0f
val parentHeight = parent.height + 0f
//绘制高亮区域的边界线
c.drawLine(0f, 0f, parentWidth, 0f, highlightLinePaint)
c.drawLine(0f, highlightLineTop, parentWidth, highlightLineTop, highlightLinePaint)
c.drawLine(0f, highlightLineBottom, parentWidth, highlightLineBottom, highlightLinePaint)
c.drawLine(0f, parentHeight, parentWidth, parentHeight, highlightLinePaint)

if (enableHighlightMarker) {
//绘制高亮标记线
c.drawRect(
0f, highlightLineTop, highlightMarkerWidth.toFloat(),
highlightLineBottom, highlightMarkerPaint
)
}
if (enableHintText) {
val textY = highlightLineTop + layoutManager.itemHeight / 2 + hintTextDrawingOffsetY
val textX = parent.width - hintTextPaint.measureText(hintText) - hintTextRightMargin
//绘制年月日文字
c.drawText(hintText, textX, textY, hintTextPaint)
}
}

其他就不贴代码了,请参考源码。

无限循环

标准模式下的实现相对容易,如图

但是无限循环,乍看之下不知道从何下手,既然如此,那干脆先把图画出来,再来分析。

补充布局

如图,普通模式第一个元素是1,从上往下依次是13,1的上边应该是最后一个元素31,再往上是30、29、……。所以说无限循环的本质就是头接尾、尾接头。
先考虑scrollOffsetY = 0的情况,上图即是,1
3的布局保持不变,重点是1上边的31、30。我姑且把它们称之为负序列布局,把之前的layoutChildren方法稍微修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private fun layoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
//正序列布局
val (headIndex, tailIndex) = getLayoutRange(state.itemCount)
posLayoutHead = if (headIndex >= 0) headIndex else 0
posLayoutTail = if (tailIndex >= 0) tailIndex else 0
if (headIndex >= 0 && tailIndex >= 0) {
for (i in (headIndex..tailIndex)) {
val child = getItemView(recycler, i)
val decoratedWidth = getDecoratedMeasuredWidth(child)
val childTop = getLayoutTop(i)
val childBottom = childTop + getDecoratedMeasuredHeight(child)
layoutDecorated(child, 0, childTop, decoratedWidth, childBottom)
}
}
//负序列布局
if (isInfiniteScrollEnabled(state.itemCount)) {
val (negHeadIndex, negTailIndex) = getNegativeLayoutRange(state.itemCount)
negLayoutHead = if (negHeadIndex >= 0) negHeadIndex else 0
negLayoutTail = if (negTailIndex >= 0) negTailIndex else 0
if (negHeadIndex >= 0 && negTailIndex >= 0) {
for (i in (negTailIndex downTo negHeadIndex)) {
val child = getItemView(recycler, i)
val decoratedWidth = getDecoratedMeasuredWidth(child)
val childTop = getNegativeLayoutTop(i, state.itemCount)
val childBottom = childTop + getDecoratedMeasuredHeight(child)
layoutDecorated(child, 0, childTop, decoratedWidth, childBottom)
}
}
}

val removalList = ArrayList<RecyclerView.ViewHolder>()
removalList.addAll(recycler.scrapList)
removalList.forEach { holder ->
removeView(holder.itemView)
recycler.recycleView(holder.itemView)
}
}

计算负序列布局的布局范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private fun getNegativeLayoutRange(itemCount: Int): Pair<Int, Int> {
var headIndex = -1
var tailIndex = -1
for (i in (itemCount - 1 downTo 0)) {
val childTop = getNegativeLayoutTop(i, itemCount)
val childBottom = childTop + requiredItemHeight
if (isLayoutInVisibleArea(childTop, childBottom)) {
tailIndex = i
break
}
}

for (i in (tailIndex - 1 downTo 0)) {
val childTop = getNegativeLayoutTop(i, itemCount)
val childBottom = childTop + requiredItemHeight
if (childBottom < 0) {
headIndex = i + 1
break
}
}
if (headIndex < 0) {
headIndex = 0
}
return headIndex to tailIndex
}

还需要一个负序列位置计算公式:

1
2
3
4
5
6
7
8
9
/**
* 获取指定Item在特定scrollOffsetY值时的顶部位置(负序列定位规则)
*
* @param index Item在适配器中的Index值
* @param itemCount 适配器中的itemCount
*/
private fun getNegativeLayoutTop(index: Int, itemCount: Int): Int {
return (requiredSpaceCount - 1) * requiredItemHeight - scrollOffsetY - (itemCount - 1 - index) * requiredItemHeight
}

这个负序列位置计算就不能再依靠之前的位置公式了,因为负序列采用了不同的定位方式:从下往上。从最后一个元素开始,往前布局。还是采用前边的假设,所以31的顶部位置为140px,30的位置为0px,29的位置为-140px。由此可以推算出上述的计算公式。

偏移值临界点

初步的布局已经成功了,接下来就该让它动起来了。如果我手指拖动往下滑,滑到scrollOffsetY < 0 还继续往下滑,当滑动了一圈之后发现,咦?怎么上边没了?

别着急,我们分析一下为什么没了?看看Logcat,这个时候的scrollOffsetY值大概已经是-18xx了吧。往下滑,当前布局的就是负序列,再往前,负序列前边却没办法再补充一个负序列了,所以才会出现没了。同理,如果你往上滑,滑动到正序列的尾部的时候,也会没了,正序列后边不可能再补充一个正序列。说到这里,你可能会问我,那可怎么办?

其实答案很简单,我们需要处理一下scrollOffsetY的值,限定它的范围。先来看看一个临界点位置:

如图,可见区域的元素是27、28、29、30、31,这时候scrollOffsetY的值是多少?答案是有两种可能,-420px或者3920px,往下滑-420px(完全布局负序列),往上滑3920px(完全布局正序列)。也就是说,无论当前是-420px还是3920px,我们看到的元素排列样子完全相同。

那么,用这两个数值作为scrollOffsetY的范围能行吗?

答案是肯定的,如果scrollOffsetY的值大于3920px,就强制变为大于-420px的数;小于-420px,就强制变为小于3920px的数。 分别根据正负序列位置计算公式,算出正负阈值。稍微修改一下**updateScrollOffsetY()**方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun updateScrollOffsetY(dy: Int, lastOffsetY: Int, itemCount: Int) {
scrollOffsetY += dy
val childrenHeight = itemCount * requiredItemHeight
if (isInfiniteScrollEnabled(itemCount)) {
val negLenThreshold = (requiredSpaceCount + 1) * requiredItemHeight
val posLenThreshold = childrenHeight - negLenThreshold
val mod = scrollOffsetY % requiredItemHeight
if (scrollOffsetY > posLenThreshold) {
scrollOffsetY = -negLenThreshold + mod
} else if (scrollOffsetY <= -negLenThreshold) {
scrollOffsetY = posLenThreshold - mod
}
} else {
val maxOverflowHeight = childrenHeight - requiredItemHeight
if (scrollOffsetY < 0) {
scrollOffsetY = 0
} else if (scrollOffsetY > maxOverflowHeight) {
scrollOffsetY = if (maxOverflowHeight > 0) maxOverflowHeight else lastOffsetY
}
}
}

如此,真正的无限循环便实现了。

修改选中效果

虽然无限循环的效果是实现了,但还有一个工作需要完成:选中效果只做了普通模式,无限循环模式的选中效果还需要修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private fun createScrollAnimation() {
//如果是无限循环模式,则在判断选中位置时需要考虑scrollOffset分界点的问题
if (isInfiniteScrollEnabled(itemCount)) {
val negLenThreshold = (requiredSpaceCount + 1) * requiredItemHeight
val posLenThreshold = itemCount * requiredItemHeight - negLenThreshold
val criticalValue = -requiredItemHeight / 2
var targetPosition = -1
var minDistance = Int.MAX_VALUE
if (scrollOffsetY > -negLenThreshold && scrollOffsetY < criticalValue) {
//正负序列混合布局区域
for (i in (negLayoutTail downTo negLayoutHead)) {
val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY)
if (distance < minDistance) {
minDistance = distance
targetPosition = i
}
}
if (targetPosition < 0) {
targetPosition = 0
}
} else if (scrollOffsetY in criticalValue..posLenThreshold) {
//纯正序列布局区域
for (i in (posLayoutHead..posLayoutTail)) {
val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY)
if (distance < minDistance) {
minDistance = distance
targetPosition = i
}
}
}
startScrollAnimation(targetPosition, itemCount)
} else {
startScrollAnimation(findClosestItemPosition(), itemCount)
}
}

修改获取特定偏移值:

1
2
3
4
5
6
7
private fun getRequiredScrollOffset(targetPosition: Int): Int {
return if (scrollOffsetY >= -requiredItemHeight / 2) {
targetPosition * requiredItemHeight
} else {
-(itemCount - targetPosition) * requiredItemHeight
}
}

适配元素数量变化

基本功能都已经全部实现了,但还有最后一项不可忽视的工作:适配元素数量变化。当元素数量产生变化时,同时必然会导致布局产生变化,然后会引起选中元素变化。适配需要复写的方法主要有两个:onItemsAddedonItemsRemoved

1
2
3
4
5
6
7
8
9
override fun onItemsAdded(recyclerView: RecyclerView, positionStart: Int, itemCount: Int) {
super.onItemsAdded(recyclerView, positionStart, itemCount)
fixSelection(positionStart, itemCount)
}

override fun onItemsRemoved(recyclerView: RecyclerView, positionStart: Int, itemCount: Int) {
super.onItemsRemoved(recyclerView, positionStart, itemCount)
fixSelection(positionStart, -itemCount)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private fun fixSelection(positionStart: Int, changeLength: Int) {
val positionEnd = positionStart + Math.abs(changeLength)
var newPosition = selectedPosition
if (changeLength > 0) {
//添加元素
if (selectedPosition >= positionEnd) {
newPosition += Math.abs(changeLength)
}
} else if (changeLength < 0) {
//删除元素
if (selectedPosition in (positionStart until positionEnd)) {
newPosition = if (itemCount - 1 - positionEnd > positionStart) {
positionEnd
} else {
positionStart - 1
}
} else if (selectedPosition >= positionEnd) {
newPosition -= Math.abs(changeLength)
}
}
//修正由于Item数量产生变化而scrollOffsetY越界没有重新计算的问题
updateScrollOffsetY(0, 0, itemCount)
scrollToPosition(newPosition)
}

源码地址

源码地址

WebView加载相对路径下的HTML页面

1
webview.engine.load(javaClass.getResource("web/index.html").toExternalForm())

重点在于toExternalForm()方法

WebView添加Javascript交互接口

1
2
3
4
5
6
7
webview.engine.loadWorker.stateProperty()
.addListener { _: ObservableValue<out Worker.State>, _: Worker.State, newValue: Worker.State ->
if (newValue == Worker.State.SUCCEEDED) {
val win = wvContent.engine.executeScript("window") as JSObject
win.setMember("app", AppEnv())
}
}

在HTML页面中调用Java端代码应该调用app.doSomething()

1
2
3
4
inner class AppEnv {
fun doSomething() {
}
}

WebView添加样式

1
webview.engine.userStyleSheetLocation = javaClass.getResource("scrollbar_style.css").toExternalForm()

scrollbar_style.css是相对此代码文件同一目录下的

UI开发由于使用的是HTML、CSS、Javascript,所以以前怎么来,现在还是怎么来,你甚至可以使用以前用的习惯的各种框架:AngularBootstrapJQuery等等

你可以使用Bower来管理依赖,输入以下命令来安装Bower:

1
$ npm install -g bower

然后就可以通过Bower来管理各种依赖库了:

1
$ bower install bootstrap

通过这个命令,依赖库将被安装到bower_components当中,你可以在你的HTML页面里边直接引用

搭建UI实例

安装前文所说,准备一个项目(package.json,main.js,index.html)
main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const {app, BrowserWindow} = require('electron')
const url = require('url')
const path = require('path')

let win

function createWindow() {
win = new BrowserWindow({width: 800, height: 600})
win.loadURL(url.format ({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}))
}

app.on('ready', createWindow)

安装JQuery:

1
$ npm install --save jquery

index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset = "UTF-8">
<title>Hello Electron!</title>
<link rel = "stylesheet"
href = "./bower_components/bootstrap/dist/css/bootstrap.min.css" />
</head>

<body>
<div class = "container">
<h1>This page is using Bootstrap and jQuery!</h1>
<h3 id = "click-counter"></h3>
<button class = "btn btn-success" id = "countbtn">Click here</button>
<script src = "./view.js" ></script>
</div>
</body>
</html>

新建一个view.js,拷入以下代码:

1
2
3
4
5
6
7
let $ = require('jquery')  // 引用jQuery并赋值给$符号
let count = 0
$('#click-counter').text(count.toString())
$('#countbtn').on('click', () => {
count ++
$('#click-counter').text(count)
})

输入命令,运行App:

1
$ electron ./main.js

运行结果如图:

什么是Electron

Electron是由Github研发的使用HTMLCSSJavascript开发跨平台桌面应用程序的开源开发框架。Electron通过把ChromiumNode.js结合进一个运行时中实现,它开发的app能够被打包为MacWindowsLinux平台的应用程序。

提到跨平台桌面应用开发,最先想到的应该是JavaFx。JavaFx的一大特点是传统的XML语言结合Java进行开发,能够跨平台运行。但是我个人认为JavaFx那些控件写起来远不如Android当中的控件写起来更方便。这次偶然得知使用HTML来开发跨平台应用的框架,我很兴奋,毕竟用HTML来做UI是非常方便的,得益于它丰富的框架和成熟的体系。

Electron官方网站
学习参考教程

Hello Electron

必备环境:Node.js

按照教程所说,建议把Electron安装到每一个项目的依赖中,那么安装完Node.js之后,我们就可以着手开始做第一个App了

第一个App的名字就叫HelloEla,建立一个文件夹(D:\Work\ElectronProject\HelloEla),进入这个文件夹,右键点击空白处选择 Git Bash Here

输入并执行npm init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: (helloela)
version: (1.0.0)
description:
entry point: (index.js)
test command:
git repository:
keywords:
author: peceoqicka
license: (ISC)
About to write to D:/Work/ElectronProject/HelloEla/package.json:

{
"name": "helloela",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "peceoqicka",
"license": "ISC"
}

Is this ok? (yes) yes

一路回车即可,在提示author时输入你的名称,最后确认时回车会在HelloEla目录下生成一个名为package.json的文件

输入以下命令安装Electron:

1
$ npm install -g electron

安装完成后可以输入以下命令检测是否正确安装,如果正确安装会提示版本号(我的是v2.0.4):

1
$ electron -version

新建一个文件名为main.js,并输入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const {app, BrowserWindow} = require('electron') 
const url = require('url')
const path = require('path')

let win

function createWindow() {
win = new BrowserWindow({width: 800, height: 600})
win.loadURL(url.format ({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}))
}

app.on('ready', createWindow)

打开刚才生成的package.json,修改main的值为main.js

接着再新建一个文件命名为index.html,打开文件,拷贝以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset = "UTF-8">
<title>Hello Electron!</title>
</head>

<body>
<h1>Hello Electron!</h1>
We are using node <script>document.write(process.versions.node)</script>,
Chrome <script>document.write(process.versions.chrome)</script>,
and Electron <script>document.write(process.versions.electron)</script>.
</body>
</html>

然后在刚才打开的bash窗口中输入命令运行App:

1
$ electron ./main.js

能够看到一个弹出的窗口:

FlowLayout

FlowLayout就是流式布局,为什么要起这么一个名字?因为大家都这么叫它,不管名字起得多么花哨,功能都是大同小异的。如图:

我为什么要实现流式布局?

源于工作的需要,最近要做这么一个需求,有这么一些标签(数组,数量不确定,字数不确定),他们需要一个一个的往一个容器里边装,每个标签宽度不确定,如果标签在这一排放不下(剩余宽度不够),那么就要放到下一排。而且因为是数组,所以首先联想到的就是Adapter。

我也试着找寻官方提供的控件和第三方的开源框架,发现都没有完全满足我的需求的:

  • FlexBoxLayout,很强大的控件,但不支持适配器
  • FlowLayout,大神写的控件,但是定制得太多
  • 还有一些其他的,我就不细说了

我需要的是一个容器控件,能够支持适配器,负责它所有的ChildView的布局(不需要ChildView的缓存),ChildView的宽高不定,不能限定每一行放多少个ChildView,不需要任何方向的滚动。所以,综上,我还是自己定义吧,与此同时,也能在自己实现的过程中提高自己的开发能力。

那么开始吧

自定义ViewGroup是老生常谈的话题,一个自定义ViewGroup的基本流程就是获取布局参数->测量->布局

框架搭建

FlowLayout我使用Kotlin来实现,基础框架搭建如下:

1
2
3
4
5
6
7
8
9
class FlowLayout : ViewGroup {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
getParams(context, attrs, defStyleAttr)
}

constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context) : this(context, null)

}

获取布局参数

这里不需要过多的参数,只要两个,水平和竖直两个方向的边距:

1
2
3
4
5
6
7
private fun getParams(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
context.obtainStyledAttributes(attrs, R.styleable.FlowLayout, defStyleAttr, 0).apply {
columnSpace = getDimensionPixelSize(R.styleable.FlowLayout_fl_columnSpace, 0)
rowSpace = getDimensionPixelSize(R.styleable.FlowLayout_fl_rowSpace, 0)
recycle()
}
}

测量

测量的主要目的是告诉FlowLayout的父级容器,我FlowLayout要占多大的空间。首先父级会告诉我们,我的测量模式和预设宽高:

1
2
3
4
val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
val heightMode = View.MeasureSpec.getMode(heightMeasureSpec)
val widthSize = View.MeasureSpec.getSize(widthMeasureSpec)
val heightSize = View.MeasureSpec.getSize(heightMeasureSpec)

测量模式有且仅有三种:

  • EXACTLY,“定了就这么多”,父级告诉自己一个确定的值
  • AT_MOST,“最多就这么多了”,父级给定一个最大值
  • UNSPECIFIED,“要多少有多少”,这种情况很少见

那么是不是在EXACTLY模式下,就不用测量了呢?当然不是,这里测量之后,还要给每个子View设置参数,方便在布局的时候计算每个子View放置的位置。

在测量时,需要考虑左右的padding:

1
val availableWidth = widthSize - paddingStart - paddingEnd

测量子View时的基本方法是循环,在每次循环时判断是否需要换行,参考的依据就是,当前行已经占有的宽度加上当前ChildView要占据的宽度:

1
2
3
4
5
6
7
val childWidth = childView.measuredWidth + layoutParams.marginStart + layoutParams.marginEnd
val predictLineWidth = lineWidth + (if (lineWidth == 0) childWidth else (columnSpace + childWidth))
if (predictLineWidth <= availableWidth) {
//不换行
} else {
//换行
}

为了便于布局,要在测量时,计算出每一个ChildView的行列号并赋值给相应的LayoutParams。因此我们需要自定义一个LayoutParams类:

1
2
3
4
5
6
7
8
9
10
open class LayoutParams : MarginLayoutParams {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
@Suppress("unused")
constructor(width: Int, height: Int) : super(width, height)

constructor(source: ViewGroup.LayoutParams) : super(source)

var layoutColumn = 0
var layoutRow = 0
}

循环计算时,需要几个变量:

1
2
3
4
5
6
var maxLineWidth = 0 //最大行宽
var maxLineHeight = 0 //最大行高
var lineWidth = 0 //行宽
var totalHeight = 0 //所有行占用的总高度
var columnIndex = 0 //列顺序
var rowIndex = 0 //行顺序

循环计算的完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
(0 until childCount).forEach {
val childView = getChildAt(it)
measureChildWithMargins(childView, widthMeasureSpec, 0, heightMeasureSpec, 0)
val layoutParams = childView.layoutParams as FlowLayout.LayoutParams

val childWidth = childView.measuredWidth + layoutParams.marginStart + layoutParams.marginEnd
val childHeight = childView.measuredHeight + layoutParams.topMargin + layoutParams.bottomMargin

val predictLineWidth = lineWidth + (if (lineWidth == 0) childWidth else (columnSpace + childWidth))

if (predictLineWidth <= availableWidth) {
//不换行
lineWidth = predictLineWidth
maxLineHeight = Math.max(maxLineHeight, childHeight)

layoutParams.layoutColumn = columnIndex
layoutParams.layoutRow = rowIndex

columnIndex++
} else {
//换行
columnIndex = 0
rowIndex++
maxLineWidth = Math.max(lineWidth, maxLineWidth)
totalHeight += (maxLineHeight + rowSpace)
lineHeightList.add(maxLineHeight)

layoutParams.layoutColumn = columnIndex
layoutParams.layoutRow = rowIndex

//如果恰好是最后一行,最大行高值不应该置0,在循环完成之后补上最后一行的行高
maxLineHeight = if (it == childCount - 1) maxLineHeight else 0
lineWidth = childWidth
columnIndex++
}
}

totalHeight += maxLineHeight
lineHeightList.add(maxLineHeight)

其中有几个要点如下:

  • 循环完成之后要补上最后一行的行高

  • 换行时将columnIndex置0,rowIndex加1,然后赋值给对应的LayoutParams,赋值完成之后一定要给columnIndex加1

布局

布局相对简单,因为大多数的工作前边测量时已经完成了:

1
2
3
4
5
6
7
8
9
10
11
(0 until childCount).forEach {
val childView = getChildAt(it)
val layoutParams = childView.layoutParams as FlowLayout.LayoutParams

val cLeft = paddingStart + layoutParams.marginStart + calculateLeftPosition(it, layoutParams.layoutColumn)
val cTop = paddingTop + calculateTopPosition(it, layoutParams.layoutRow)
val cRight = cLeft + childView.measuredWidth
val cBottom = cTop + childView.measuredHeight

childView.layout(cLeft, cTop, cRight, cBottom)
}

在计算childView的左边的距离时,使用递归来计算,不要算上本身的左边距:

1
2
3
4
5
6
7
8
9
10
 private fun calculateLeftPosition(layoutIndex: Int, colIndex: Int): Int {
return if (colIndex > 0) {
val previousChild = getChildAt(layoutIndex - 1)
val previousLayoutParams = previousChild.layoutParams as FlowLayout.LayoutParams
val previousWidth = previousChild.measuredWidth + previousLayoutParams.marginStart + previousLayoutParams.marginEnd
columnSpace + previousWidth + calculateLeftPosition(layoutIndex - 1, colIndex - 1)
} else {
0
}
}

上边的距离则相对简单,原理同计算左边距:

1
2
3
4
5
6
7
private fun calculateTopPosition(layoutIndex: Int, rowIndex: Int): Int {
return if (rowIndex > 0) {
rowSpace + lineHeightList[rowIndex] + calculateTopPosition(layoutIndex - 1, rowIndex - 1)
} else {
0
}
}

监听适配器刷新

当我们在调用适配器的notifyDataSetChanged方法的时候,FlowLayout需要作出响应,刷新布局,因此,需要一个DataSetObserver:

1
2
3
4
5
6
7
8
9
10
11
inner class AdapterDataSetObserver : DataSetObserver() {
override fun onChanged() {
resetData()
requestLayout()
}

override fun onInvalidated() {
resetData()
requestLayout()
}
}

调用notifyDataSetChanged()会触发这里的onChanged()方法,调用notifyDataSetInvalidated()会触发这里的onInvalidated()方法,但是还没完,写好了之后还要“注册”:

1
2
3
4
5
6
7
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if ((adapter != null) and (adapterDataSetObserver == null)) {
adapterDataSetObserver = AdapterDataSetObserver()
adapter?.registerDataSetObserver(adapterDataSetObserver)
}
}

在重设适配器时也需要重新“绑定”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fun setAdapter(adapter: BaseAdapter?) {
if (this.adapter !== adapter) {
this.adapter?.let { a ->
adapterDataSetObserver?.let { o ->
a.unregisterDataSetObserver(o)
}
}
adapter?.let {
adapterDataSetObserver = AdapterDataSetObserver()
it.registerDataSetObserver(adapterDataSetObserver)

this.adapter = it.apply {
(0 until this@apply.count).forEach {
val childView = this@apply.getView(it, null, this@FlowLayout)
addView(childView,generateLayoutParams(childView.layoutParams))
}
}

requestLayout()
} ?: let {
this.adapter = null
removeAllViews()
}
}
}

剩下的就没什么可说的了,当然这个FlowLayout设计肯定还是有缺陷的,以后会逐渐的改进

Github地址:https://github.com/peceoqicka/FlowLayoutDemo