Android实现聊天型笔记编辑器

Android实现聊天型笔记编辑器

在《iOS Messages功能分析》和《Android实现聊天界面》中,我们讨论了聊天编辑器的功能以及技术实现方案。现在开始着手实现编辑器功能。

一、目标

实现聊天型笔记编辑器。

第一个实现版本只支持文字信息,未来版本将加入图片、方程、录音、网页、……等功能。

二、实现过程

1. 定义数据结构

  • 三层结构的数据体系
层次 类定义 描述
存储数据 app.haiyunshan.whatsnote.chat.entry 与数据相对应的结构
Chat 对话列表集合
ChatEntry 消息基类
TextEntry 文字信息类
逻辑数据 app.haiyunshan.whatsnote.chat.entity 将原始的基础数据转换成逻辑数据
Message Chat对应
MessageEntity ChatEntry对应
TextEntity TextEntry对应
交互数据 app.haiyunshan.whatsnote.chat.adapter 与用户交互相关的数据
ChatProvider Message对应
ChatItem MessageEntity对应
TextItem TextEntity对应
DateItem 根据MessageEntity自动创建的日期项

1. ChatEntry

  • type——定义信息类型,文字、图片、录音、网址、方程、……
  • created——消息创建时间
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
public class ChatEntry extends BaseEntry {

@SerializedName("type")
String type;

@SerializedName("created")
String created;

public ChatEntry(String id, String type) {
super(id);

this.type = type;

DateTime date = DateTime.now();
this.created = date.toString();
}

public String getType() {
return type;
}

public String getCreated() {
return created;
}
}

2. MessageEntity

ChatEntry对应,将存储数据转换成逻辑数据,例如。

时间String转换为时间DateTime

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
public class MessageEntity<T extends ChatEntry> {

DateTime created;

T entry;
Message parent;

public MessageEntity(Message parent, T entry) {
this.parent = parent;
this.entry = entry;
}

public DateTime getCreated() {
if (created != null) {
return created;
}

created = DateTime.parse(entry.getCreated());
if (created == null) {
created = DateTime.now();
}

return created;
}
}

3. ChatItem

MessageEntity对应,但增加了交互和界面相关的属性,例如

  • bubble——定义消息的气泡形状
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
public class ChatItem<E extends MessageEntity> {

public static final int BUBBLE_NORMAL = 0;
public static final int BUBBLE_TAIL = 1;

E entity;
int bubble = BUBBLE_TAIL;

public ChatItem(E entity) {
this.entity = entity;
}

public E getEntity() {
return entity;
}

public DateTime getCreated() {
return entity.getCreated();
}

public int getBubble() {
return bubble;
}

public void setBubble(int bubble) {
this.bubble = bubble;
}
}

2. 定义ViewHolder

创建与ChatItem相对应的ViewHolder。

交互Holder 交互数据 描述
ChatViewHolder ChatItem 消息Holder基类
TextViewHolder TextItem 文本Holder
DateViewHolder DateItem 因为DateItem并不是实体消息,因此并不继承自ChatViewHolder

1. ChatViewHolder

ViewStub根据不同的消息类型创建不同的控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public abstract class ChatViewHolder<E extends ChatItem> extends BridgeViewHolder<E> {

ViewStub viewStub;

@Keep
public ChatViewHolder(View itemView) {
super(itemView);
}

@Override
public void onViewCreated(@NonNull View view) {
this.viewStub = view.findViewById(R.id.stub);
}

public int getBubble(ChatItem item) {
int resId = R.drawable.ic_outgoing_bubble_tail;
if (item.getBubble() == ChatItem.BUBBLE_NORMAL) {
resId = R.drawable.ic_outgoing_bubble;
}

return resId;
}
}

2. TextViewHolder

考虑到将来可能加入incoming的消息类型,因此将TextViewHolder设计为抽象类,并提供了OutgoingTextViewHolder为具体实现。

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
public abstract class TextViewHolder extends ChatViewHolder<TextItem> {

protected TextView textView;

@Keep
public TextViewHolder(View itemView) {
super(itemView);
}

@Override
public void onViewCreated(@NonNull View view) {
super.onViewCreated(view);

viewStub.setLayoutResource(R.layout.layout_text_chat_item);
View stub = viewStub.inflate();

{
this.textView = stub.findViewById(R.id.tv_text);
}
}

@Override
public void onBind(TextItem item, int position) {

textView.setBackgroundResource(getBubble(item));

TextEntity entity = item.getEntity();
textView.setText(entity.getText());
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OutgoingTextViewHolder extends TextViewHolder {

public static final int LAYOUT_RES_ID = R.layout.layout_outgoing_chat_item;

@Keep
public OutgoingTextViewHolder(View itemView) {
super(itemView);
}

@Override
public int getLayoutResourceId() {
return LAYOUT_RES_ID;
}

}

4. DateViewHolder

定义显示的时间。

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
public class DateViewHolder extends BridgeViewHolder<DateItem> {

public static final int LAYOUT_RES_ID = R.layout.layout_date_chat_item;
TextView textView;

@Keep
public DateViewHolder(View itemView) {
super(itemView);
}

@Override
public int getLayoutResourceId() {
return LAYOUT_RES_ID;
}

@Override
public void onViewCreated(@NonNull View view) {
this.textView = view.findViewById(R.id.tv_text);
}

@Override
public void onBind(DateItem item, int position) {
textView.setText(getTime(item.getCreated()));
}
}

3. 实现ComposeChatFragment

最后将所有数据组合起来,实现最终编辑器。

目前暂未实现存储功能,每次将创建新的Message进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);

{
this.message = new Message(UUIDUtils.next());
this.provider = new ChatProvider(recyclerView, transform(message));
}

{
this.adapter = new BridgeAdapter(getActivity(), provider);

adapter.bind(DateItem.class, new BridgeBuilder(DateViewHolder.class, DateViewHolder.LAYOUT_RES_ID));
adapter.bind(TextItem.class, new BridgeBuilder(OutgoingTextViewHolder.class, OutgoingTextViewHolder.LAYOUT_RES_ID));
}

{
recyclerView.setAdapter(adapter);
}
}

三、技术细节

1. 显示时间

聊天型笔记的优点之一是自动为消息记录插入时间,可以方便追溯记录的时间点。

显示的时间以手机当前时间作为参考点。

间隔天数 详细 描述
< 0 理论上不可能出现,但用户调整手机时间,则会出现该情况。
== -1 明天
== -2 后天
else 星期一、星期二、星期三、星期四、星期五、星期六、星期日
== 0 == 0 今天
== 1 == 1 昨天
== 2 == 2 前天
< 7 < 7 星期一、星期二、星期三、星期四、星期五、星期六、星期日
else else 超过一周时间,以月份进行比较
间隔月数 描述
< 12 以月日,并添加"周一、周二、周三、周四、周五、周六、周日"形式显示
>= 12 以年月日形式显示

需要注意的是

  • 间隔天数——以一天开始时间计算,也就是0时0分0秒。

  • 间隔月数——以一个月开始时间见计算,也就是第1天0时0分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
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
private static final String[] DAY_OF_WEEK = {
"周一",
"周二",
"周三",
"周四",
"周五",
"周六",
"周日"
};

private static final String[] DAY_IN_WEEK = {
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
"星期日"
};

static CharSequence getTime(DateTime dateTime) {

StringBuilder sb = new StringBuilder();

DateTime now = DateTime.now();

int days = Days.daysBetween(dateTime.withTimeAtStartOfDay(), now.withTimeAtStartOfDay()).getDays();
if (days < 0) { // future time

if (days == -1) { // tomorrow
sb.append("明天");
} else if (days == -2) { // the day after tomorrow
sb.append("后天");
} else { // future
sb.append(getDayOfWeek(dateTime, true));
}

} else if (days == 0) { // today
sb.append("今天");
} else if (days == 1) { // yesterday
sb.append("昨天");
} else if (days == 2) { // the day before yesterday
sb.append("前天");
} else if (days < 7) { // in a week
sb.append(getDayOfWeek(dateTime, true));
} else {

DateTime a = new DateTime(dateTime.getYear(), dateTime.getMonthOfYear(), 1, 0, 0);
DateTime b = new DateTime(now.getYear(), now.getMonthOfYear(), 1, 0, 0);

int months = Months.monthsBetween(a, b).getMonths();
if (months < 12) {
sb.append(String.format("%1$d月%2$d日 %3$s", dateTime.getMonthOfYear(), dateTime.getDayOfMonth(), getDayOfWeek(dateTime, false)));
}
}

// if empty, append year.month.day
if (sb.length() == 0) {
sb.append(String.format("%1$d年%2$d月%3$d日", dateTime.getYear(), dateTime.getMonthOfYear(), dateTime.getDayOfMonth()));
}

// append time
{
sb.append(' ');
sb.append(String.format("%1$02d:%2$02d", dateTime.getHourOfDay(), dateTime.getMinuteOfHour()));
}


return sb;
}

static CharSequence getDayOfWeek(DateTime dateTime, boolean inAWeek) {
int value = dateTime.getDayOfWeek();

String[] array = inAWeek? DAY_IN_WEEK: DAY_OF_WEEK;
return array[value - 1];
}

2. 关闭软键盘

Android关闭软键盘的方式通常为点击按钮进行关闭。

全面屏之后,加入了一些新的手势以实现返回操作,从而关闭软键盘。

但主要的操作方式为从左或从右或从下向屏幕滑动实现返回操作。

目前的手势操作虽然方便了关闭软键盘的操作,但还是不够直观。

最直观的方式应该是在屏幕内向下滑动,到达软键盘区域,关闭软键盘

将该操作抽象为KeyboardDrawer来实现该功能。

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
public class KeyboardDrawer extends FrameLayout {

public KeyboardDrawer(@NonNull Context context) {
this(context, null);
}

public KeyboardDrawer(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public KeyboardDrawer(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}

public KeyboardDrawer(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

boolean hide = ev.getY() > this.getHeight();
if (hide) {
hideSoftInput(getContext());
}

return super.dispatchTouchEvent(ev);
}


/**
*
* @param context
*/
private static final void hideSoftInput(Context context) {
if (!(context instanceof Activity)) {
return;
}

View view = ((Activity)context).getCurrentFocus();

if (view == null) {
return;
}

InputMethodManager manager = (InputMethodManager)(context.getSystemService(Context.INPUT_METHOD_SERVICE));
manager.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
}

3. 限定编辑框高度

文本编辑框需要支持2个功能

  1. 多行文本
  2. 惯性滑动

EditText本身不支持惯性滑动,必须组合ScrollView才能实现。结合ScrollView实现滚动不能设置maxLines属性,否则无法滚动。但又必须限制文本编辑框的高度,以防止布局出现问题。

因此,只能限制ScrollView高度以实现该功能。

定义ScrollView子类来实现该功能。

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
public class ConstraintScrollView extends NestedScrollView {

int maxHeight;

public ConstraintScrollView(@NonNull Context context) {
this(context, null);
}

public ConstraintScrollView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public ConstraintScrollView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

{
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ConstraintScrollView);
this.maxHeight = a.getDimensionPixelSize(R.styleable.ConstraintScrollView_maxHeight, -1);
a.recycle();
}
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

if (maxHeight > 0) {
int width = this.getMeasuredWidth();
int height = this.getMeasuredHeight();
height = (height > maxHeight)? maxHeight: height;

this.setMeasuredDimension(width, height);
}


}
}

4. 处理滑动到列表底部

在2种情况下列表需要滑动到底部

  1. 用户点击编辑框,启动软键盘时
  2. 用户发送新信息后

还有另外一种情况是,文本编辑框高度增加时。

这种情况与情况1在代码上的触发时机相同,均为列表布局发生变化。

  • 布局发生变化时,滚动到底部
1
2
3
4
5
6
7
8
9
10
recyclerView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (bottom < oldBottom) {
recyclerView.post(() -> {
int count = adapter.getItemCount();
if (count > 0) {
provider.scrollToPosition(count - 1);
}
});
}
});
  • 发送新信息
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
public void add(ChatItem item) {
if (item == null) {
return;
}

int position = list.size();
int count = 0;

boolean shouldInsertDate = this.shouldInsertDate(list, item);
if (shouldInsertDate) {
list.add(new DateItem(item.getEntity()));
++count;
}

{
list.add(item);
++count;
}

RecyclerView.Adapter adapter = recyclerView.getAdapter();
if (adapter != null) {
adapter.notifyItemRangeInserted(position, count);
}

if (adapter != null && !shouldInsertDate) {
ChatItem current = list.get(position - 1);
ChatItem next = item;
boolean result = shouldChangeBubble(current, next);
if (result) {
current.setBubble(ChatItem.BUBBLE_NORMAL);

adapter.notifyItemChanged(position - 1);
}
}

{
this.scrollToPosition(list.size() - 1);
}
}
  • 实现滚动

首先判断目标是否已经显示。

  1. 未显示——滚动到指定位置

  2. 已显示——对齐到底部

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
public void scrollToPosition(int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
if (adapter == null) {
return;
}

View child = null;

{
int count = recyclerView.getChildCount();
for (int i = 0; i < count; i++) {
int pos = recyclerView.getChildAdapterPosition(recyclerView.getChildAt(i));
if (pos == position) {
child = recyclerView.getChildAt(i);
break;
}
}
}

if (child == null) {
recyclerView.smoothScrollToPosition(position);
} else {
if (child.getBottom() > recyclerView.getHeight() - recyclerView.getTop()) {
int offset = child.getBottom() - recyclerView.getHeight();
offset += recyclerView.getPaddingBottom();

recyclerView.scrollBy(0, offset);
}
}
}

5. 插入时间戳

时间戳信息从消息信息中提取而来,不会进行保存。

时间戳与消息最近的一条消息相关联。

  • 时间戳的插入规则
间隔天数 处理方式
== 0 离最新一条消失超过1个小时,插入时间戳
!= 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
static boolean shouldInsertDate(List<ChatItem> list, ChatItem item) {
if (list.isEmpty()) {
return true;
}

ChatItem last = list.get(list.size() - 1);
DateTime date = last.getCreated();
DateTime now = item.getCreated();

// more than one hour, insert a date
{
int hours = Hours.hoursBetween(date, now).getHours();
hours = Math.abs(hours);
if (hours != 0) {
return true;
}
}

// a new day, inset a date
{
int days = Days.daysBetween(date.withTimeAtStartOfDay(), now.withTimeAtStartOfDay()).getDays();
days = Math.abs(days);
if (days != 0) {
return true;
}
}

return false;
}

6. 改变气泡形状

目前气泡有2种形状。

  1. 普通
  2. 尖角
  • 气泡形状规则
气泡形状 规则
尖角气泡 默认气泡形状都为尖角气泡
普通气泡 与最新一条消息比较,3分钟以内显示为普通气泡
  • 实现代码
1
2
3
4
5
6
7
8
9
10
static boolean shouldChangeBubble(ChatItem current, ChatItem next) {
DateTime date = current.getCreated();
DateTime now = next.getCreated();

int diff = Minutes.minutesBetween(date, now).getMinutes();
diff = Math.abs(diff);
boolean result = (diff < 3);

return result;
}

四、开发过程回顾

从定义数据结构开始,我们依次定义了抽象数据,并没文字消息定义了具体实现。

之后解决了编辑器的一些用户体验细节。

  1. 时间戳显示规则
  2. 优雅关闭软键盘
  3. 限定编辑框高度,同时保证惯性滑动
  4. 自动将列表滑动到底部,保证看到新的消息
  5. 在消息列表中插入时间戳
  6. 通过改变气泡形状来进一步展示消息的时间关系

五、接下来

只支持文字信息的初级聊天型笔记编辑器开发到此结束。

接下来保存数据,并整合到神马笔记中。

六、Finally

~一切有为法~如梦幻泡影~如露亦如电~应作如是观~