flutter聊天界面-聊天列表 下拉加载更多历史消息
在之前实现了flutter聊天界面的富文本展示内容、自定义表情键盘实现、加号【➕】更多展开相机、相册等操作Panel、消息气泡展示实现Flexible。这里把实现的聊天界面的滑动列表及下拉加载更多历史消息记录一下
聊天界面的列表使用ListView。
一、效果图
二、ListView
ListView是滚动组件,它可以沿一个方向线性排布所有子组件,并且它也支持列表项懒加载(在需要时才会创建)。
ListView({
...
//可滚动widget公共参数
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
EdgeInsetsGeometry? padding,
//ListView各个构造函数的共同参数
double? itemExtent,
Widget? prototypeItem, //列表项原型,后面解释
bool shrinkWrap = false,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
double? cacheExtent, // 预渲染区域长度
//子widget列表
List<Widget> children = const <Widget>[],
})
后续聊天界面会用到reverse、physics、controller等
三、聊天界面消息列表
聊天界面列表滚动使用的是ListView.builder。
需要设置shrinkWrap
shrinkWrap:该属性表示是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认情况下,ListView会在滚动方向尽可能多的占用空间。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true。
reverse:设置reverse为ture,内容会倒过来显示。
3.1、聊天列表
// 聊天列表
Widget buildScrollConfiguration(
ChatContainerModel model, BuildContext context) {
return ListView.builder(
physics: AlwaysScrollableScrollPhysics(),
key: chatListViewKey,
shrinkWrap: true,
addRepaintBoundaries: false,
controller: scrollController,
padding:
const EdgeInsets.only(left: 0.0, right: 0.0, bottom: 0.0, top: 0.0),
itemCount: messageList.length + 1,
reverse: true,
clipBehavior: Clip.none,
itemBuilder: (BuildContext context, int index) {
if (index == messageList.length) {
if (historyMessageList != null && historyMessageList!.isEmpty) {
return const ChatNoMoreIndicator();
}
return const ChatLoadingIndicator();
} else {
CommonChatMessage chatMessage = messageList[index];
return ChatCellElem(
childElem: MessageElemHelper.layoutCellElem(chatMessage),
chatMessage: chatMessage,
onSendFailedIndicatorPressed: (CommonChatMessage chatMessage) {
onSendFailedIndicatorPressed(context, chatMessage);
},
onBubbleTapPressed: (CommonChatMessage chatMessage) {
onBubbleTapPressed(context, chatMessage);
},
onBubbleDoubleTapPressed: (CommonChatMessage chatMessage) {
onBubbleDoubleTapPressed(context, chatMessage);
},
onBubbleLongPressed: (CommonChatMessage chatMessage,
LongPressStartDetails details,
ChatBubbleFrame? chatBubbleFrame) {
onBubbleLongPressed(
context, chatMessage, details, chatBubbleFrame);
},
);
}
},
);
}
3.2、聊天界面条目较少时候,iOS滚动范围较小、无法回弹问题
这个问题,这里使用的是CustomScrollView来进行嵌套ListView。CustomScrollView 的主要功能是提供一个公共的 Scrollable 和 Viewport,来组合多个 Sliver。
具体实现代码
// 嵌套的customScrollView
Widget buildCustomScrollView(ChatContainerModel model, BuildContext context) {
return LayoutBuilder(
builder: (BuildContext lbContext, BoxConstraints constraints) {
double layoutHeight = constraints.biggest.height;
return CustomScrollView(
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.all(0.0),
sliver: SliverToBoxAdapter(
child: Container(
alignment: Alignment.topCenter,
height: layoutHeight,
child: buildScrollConfiguration(model, context),
),
),
),
],
);
});
}
3.3、使用ListView的reverse为true时候,导致条目太少的时候会从下往上显示,导致顶部大片空白
导致条目太少的时候会从下往上显示,导致顶部大片空白的情况是由于界面及下面的表情键盘、输入框等使用的是Column控件。所以要用到Expanded来填充,Expanded组件强制子组件填充可用空间,Expanded会强制填充剩余留白空间。
Widget buildListContainer(ChatContainerModel model, BuildContext context) {
return Expanded(
child: Container(
decoration: BoxDecoration(
color: ColorUtil.hexColor(0xf7f7f7),
),
clipBehavior: Clip.hardEdge,
alignment: Alignment.topCenter,
child: isNeedDismissPanelGesture
? GestureDetector(
onPanDown: handlerGestureTapDown,
child: buildCustomScrollView(model, context),
)
: buildCustomScrollView(model, context),
),
);
}
// 界面及下面的表情键盘、输入框等使用的是Column控件
return Container(
key: chatContainerKey,
width: double.infinity,
height: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildChatStatisticsBar(model),
ChatAnnouncementBar(
announcementNotice: model.announcementNotice,
onAnnouncementPressed: () {
onAnnouncementPressed(model.announcementNotice);
},
),
buildListContainer(model, context),
ChatNavigatorBar(
onNavigatorItemPressed: (CommNavigatorEntry navigatorEntry) {
onNavigatorItemPressed(navigatorEntry, model);
},
navigatorEntries: model.navigatorEntries,
),
ChatInputBar(
chatInputBarController: chatInputBarController,
moreOptionEntries: model.moreOptionEntries,
showPostEnterButton: checkShowPostAndStatistics(model),
),
],
),
);
3.4、列表滑动弹性效果
需要自定义ChatScrollPhysics,该类继承ScrollPhysics
实现下滑加载带弹性效果,上滑屏蔽弹性效果。(BouncingScrollPhysics是上下都有弹性效果)
class ChatScrollPhysics extends ScrollPhysics {
/// Creates scroll physics that bounce back from the edge.
const ChatScrollPhysics({ScrollPhysics? parent}) : super(parent: parent);
ChatScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ChatScrollPhysics(parent: buildParent(ancestor));
}
/// The multiple applied to overscroll to make it appear that scrolling past
/// the edge of the scrollable contents is harder than scrolling the list.
/// This is done by reducing the ratio of the scroll effect output vs the
/// scroll gesture input.
///
/// This factor starts at 0.52 and progressively becomes harder to overscroll
/// as more of the area past the edge is dragged in (represented by an increasing
/// `overscrollFraction` which starts at 0 when there is no overscroll).
double frictionFactor(double overscrollFraction) =>
0.52 * math.pow(1 - overscrollFraction, 2);
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
print("applyPhysicsToUserOffset position:${position}, offset:${offset}");
assert(offset != 0.0);
assert(position.minScrollExtent <= position.maxScrollExtent);
if (!position.outOfRange) return offset;
final double overscrollPastStart =
math.max(position.minScrollExtent - position.pixels, 0.0);
final double overscrollPastEnd =
math.max(position.pixels - position.maxScrollExtent, 0.0);
final double overscrollPast =
math.max(overscrollPastStart, overscrollPastEnd);
final bool easing = (overscrollPastStart > 0.0 && offset < 0.0) ||
(overscrollPastEnd > 0.0 && offset > 0.0);
final double friction = easing
// Apply less resistance when easing the overscroll vs tensioning.
? frictionFactor(
(overscrollPast - offset.abs()) / position.viewportDimension)
: frictionFactor(overscrollPast / position.viewportDimension);
final double direction = offset.sign;
double applyPhysicsToUserOffset =
direction * _applyFriction(overscrollPast, offset.abs(), friction);
print("applyPhysicsToUserOffset:${applyPhysicsToUserOffset}");
return applyPhysicsToUserOffset;
}
static double _applyFriction(
double extentOutside, double absDelta, double gamma) {
assert(absDelta > 0);
double total = 0.0;
if (extentOutside > 0) {
final double deltaToLimit = extentOutside / gamma;
if (absDelta < deltaToLimit) return absDelta * gamma;
total += extentOutside;
absDelta -= deltaToLimit;
}
return total + absDelta;
}
double applyBoundaryConditions(ScrollMetrics position, double value) {
print("applyBoundaryConditions:${position},value:${value}");
return 0.0;
}
Simulation? createBallisticSimulation(
ScrollMetrics position, double velocity) {
final Tolerance tolerance = this.tolerance;
print(
"createBallisticSimulation:${position},velocity:${velocity},tolerance.velocity:${tolerance.velocity}");
if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
return BouncingScrollSimulation(
spring: spring,
position: position.pixels,
velocity: velocity,
leadingExtent: position.minScrollExtent,
trailingExtent: position.maxScrollExtent,
tolerance: tolerance,
);
}
return null;
}
// The ballistic simulation here decelerates more slowly than the one for
// ClampingScrollPhysics so we require a more deliberate input gesture
// to trigger a fling.
double get minFlingVelocity {
double aMinFlingVelocity = kMinFlingVelocity * 2.0;
print("minFlingVelocity:${aMinFlingVelocity}");
return aMinFlingVelocity;
}
// Methodology:
// 1- Use https://github.com/flutter/platform_tests/tree/master/scroll_overlay to test with
// Flutter and platform scroll views superimposed.
// 3- If the scrollables stopped overlapping at any moment, adjust the desired
// output value of this function at that input speed.
// 4- Feed new input/output set into a power curve fitter. Change function
// and repeat from 2.
// 5- Repeat from 2 with medium and slow flings.
/// Momentum build-up function that mimics iOS's scroll speed increase with repeated flings.
///
/// The velocity of the last fling is not an important factor. Existing speed
/// and (related) time since last fling are factors for the velocity transfer
/// calculations.
double carriedMomentum(double existingVelocity) {
double aCarriedMomentum = existingVelocity.sign *
math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(),
40000.0);
print(
"carriedMomentum:${aCarriedMomentum},existingVelocity:${existingVelocity}");
return aCarriedMomentum;
}
// Eyeballed from observation to counter the effect of an unintended scroll
// from the natural motion of lifting the finger after a scroll.
double get dragStartDistanceMotionThreshold {
print("dragStartDistanceMotionThreshold");
return 3.5;
}
}
3.5、去除ListView滑动波纹 - 定义ScrollBehavior
实现ScrollBehavior
class ChatScrollBehavior extends ScrollBehavior {
final bool showLeading;
final bool showTrailing;
ChatScrollBehavior({
this.showLeading: false, //不显示头部水波纹
this.showTrailing: false, //不显示尾部水波纹
});
Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
return child;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
return GlowingOverscrollIndicator(
child: child,
showLeading: showLeading,
showTrailing: showTrailing,
axisDirection: axisDirection,
color: Theme.of(context).accentColor,
);
}
return null;
}
}
四、下拉加载更多消息与没有更多消息
在下拉加载更多消息时,在listview上加ChatLoadingIndicator
在列表的最后一条进行判断。列表的
itemCount: messageList.length + 1,
if (index == messageList.length) {
if (historyMessageList != null && historyMessageList!.isEmpty) {
return const ChatNoMoreIndicator();
}
return const ChatLoadingIndicator();
}
加载更多消息Indicator代码
// 刷新的动画
class ChatLoadingIndicator extends StatelessWidget {
const ChatLoadingIndicator({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Container(
height: 60.0,
width: double.infinity,
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CupertinoActivityIndicator(
color: ColorUtil.hexColor(0x333333),
),
const SizedBox(
width: 10,
),
buildIndicatorTitle(context),
],
),
);
}
Widget buildIndicatorTitle(BuildContext context) {
return Text(
"加载中",
textAlign: TextAlign.left,
maxLines: 1000,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontStyle: FontStyle.normal,
color: ColorUtil.hexColor(0x555555),
decoration: TextDecoration.none,
),
);
}
}
当没有更多数据的时候,这时候需要显示没有更多消息了。
// 没有更多消息时候
class ChatNoMoreIndicator extends StatelessWidget {
const ChatNoMoreIndicator({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Container(
height: 40.0,
width: double.infinity,
alignment: Alignment.center,
// 不显示提示文本
child: buildIndicatorTitle(context),
);
}
Widget buildIndicatorTitle(BuildContext context) {
return Text(
"没有更多消息",
textAlign: TextAlign.left,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontStyle: FontStyle.normal,
color: ColorUtil.hexColor(0x555555),
decoration: TextDecoration.none,
),
);
}
}
监听ScorllController来控制加载等多消息
判断scrollController.position.pixels与scrollController.position.maxScrollExtent
// 滚动控制器Controller
void addScrollListener() {
scrollController.addListener(() {
LoggerManager()
.debug("addScrollListener pixels:${scrollController.position.pixels},"
"maxScrollExtent:${scrollController.position.maxScrollExtent}"
"isLoading:${isLoading}");
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent) {
if (isLoading == false) {
loadHistoryMore();
}
}
});
}
至此flutter聊天界面-聊天列表 下拉加载更多历史消息基本完成,这里有很多封装的消息类。后续的发送消息的操作等再整理。
五、小结
flutter聊天界面-聊天列表 下拉加载更多历史消息,主要实现Column中使用Expand嵌套ListView布局,设置reverse、physics、ScrollBehavior。可以解决reverse为true首导致顶部大片空白问题,去除ListView滑动波纹。之后在消息的最后一条设置为加载更多消息指示器与没有更多消息提示。
学习记录,每天不停进步。