在《iOS Messages功能分析》和《Android实现聊天界面》中,我们讨论了聊天编辑器的功能以及技术实现方案。现在开始着手实现编辑器功能。
实现聊天型笔记编辑器。
第一个实现版本只支持文字信息,未来版本将加入图片、方程、录音、网页、……等功能。
层次 | 类定义 | 描述 |
---|---|---|
存储数据 | 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 自动创建的日期项 |
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;
}
}
与ChatEntry
对应,将存储数据转换成逻辑数据,例如。
时间String
转换为时间DateTime
。
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;
}
}
与MessageEntity
对应,但增加了交互和界面相关的属性,例如
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;
}
}
创建与ChatItem
相对应的ViewHolder。
交互Holder | 交互数据 | 描述 |
---|---|---|
ChatViewHolder |
ChatItem |
消息Holder基类 |
TextViewHolder |
TextItem |
文本Holder |
DateViewHolder |
DateItem |
因为DateItem 并不是实体消息,因此并不继承自ChatViewHolder 。 |
ViewStub
根据不同的消息类型创建不同的控件。
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;
}
}
考虑到将来可能加入incoming
的消息类型,因此将TextViewHolder
设计为抽象类,并提供了OutgoingTextViewHolder
为具体实现。
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());
}
}
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;
}
}
定义显示的时间。
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()));
}
}
最后将所有数据组合起来,实现最终编辑器。
目前暂未实现存储功能,每次将创建新的Message
进行测试。
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);
}
}
聊天型笔记的优点之一是自动为消息记录插入时间,可以方便追溯记录的时间点。
显示的时间以手机当前时间作为参考点。
间隔天数 | 详细 | 描述 |
---|---|---|
< 0 | 理论上不可能出现,但用户调整手机时间,则会出现该情况。 | |
== -1 | 明天 | |
== -2 | 后天 | |
else | 星期一、星期二、星期三、星期四、星期五、星期六、星期日 | |
== 0 | == 0 | 今天 |
== 1 | == 1 | 昨天 |
== 2 | == 2 | 前天 |
< 7 | < 7 | 星期一、星期二、星期三、星期四、星期五、星期六、星期日 |
else | else | 超过一周时间,以月份进行比较 |
间隔月数 | 描述 |
---|---|
< 12 | 以月日,并添加"周一、周二、周三、周四、周五、周六、周日"形式显示 |
>= 12 | 以年月日形式显示 |
需要注意的是
间隔天数——以一天开始时间计算,也就是0时0分0秒。
间隔月数——以一个月开始时间见计算,也就是第1天0时0分0秒。
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];
}
Android关闭软键盘的方式通常为点击按钮进行关闭。
全面屏之后,加入了一些新的手势以实现返回操作,从而关闭软键盘。
但主要的操作方式为从左或从右或从下向屏幕滑动实现返回操作。
目前的手势操作虽然方便了关闭软键盘的操作,但还是不够直观。
最直观的方式应该是在屏幕内向下滑动,到达软键盘区域,关闭软键盘。
将该操作抽象为KeyboardDrawer
来实现该功能。
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);
}
}
文本编辑框需要支持2个功能
EditText
本身不支持惯性滑动,必须组合ScrollView
才能实现。结合ScrollView
实现滚动不能设置maxLines
属性,否则无法滚动。但又必须限制文本编辑框的高度,以防止布局出现问题。
因此,只能限制ScrollView
高度以实现该功能。
定义ScrollView
子类来实现该功能。
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);
}
}
}
在2种情况下列表需要滑动到底部
还有另外一种情况是,文本编辑框高度增加时。
这种情况与情况1在代码上的触发时机相同,均为列表布局发生变化。
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);
}
});
}
});
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);
}
}
首先判断目标是否已经显示。
未显示——滚动到指定位置
已显示——对齐到底部
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);
}
}
}
时间戳信息从消息信息中提取而来,不会进行保存。
时间戳与消息最近的一条消息相关联。
间隔天数 | 处理方式 |
---|---|
== 0 | 离最新一条消失超过1个小时,插入时间戳 |
!= 0 | 无论间隔多长时间,均插入时间戳 |
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;
}
目前气泡有2种形状。
气泡形状 | 规则 |
---|---|
尖角气泡 | 默认气泡形状都为尖角气泡 |
普通气泡 | 与最新一条消息比较,3分钟以内显示为普通气泡 |
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;
}
从定义数据结构开始,我们依次定义了抽象数据,并没文字消息定义了具体实现。
之后解决了编辑器的一些用户体验细节。
只支持文字信息的初级聊天型笔记编辑器开发到此结束。
接下来保存数据,并整合到神马笔记中。
~一切有为法~如梦幻泡影~如露亦如电~应作如是观~