💫 Animated height padding in inputs

This commit is contained in:
2025-11-16 20:20:24 +08:00
parent e7e3bfcadf
commit 96a919cc4e
2 changed files with 496 additions and 194 deletions

View File

@@ -146,6 +146,9 @@ class ChatRoomScreen extends HookConsumerWidget {
final inputKey = useMemoized(() => GlobalKey()); final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0); final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
// Periodic height measurement for dynamic sizing // Periodic height measurement for dynamic sizing
useEffect(() { useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) { final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
@@ -611,184 +614,428 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
} }
Widget chatMessageListWidget( Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
List<LocalChatMessage> messageList, previousInputHeight != null && previousInputHeight != inputHeight.value
) => SuperListView.builder( ? TweenAnimationBuilder<double>(
listController: listController, tween: Tween<double>(
padding: EdgeInsets.only( begin: previousInputHeight,
top: 16, end: inputHeight.value,
bottom: ),
MediaQuery.of(context).padding.bottom + duration: const Duration(milliseconds: 200),
8 + curve: Curves.easeOut,
inputHeight.value, // Leave space for chat input builder:
), (context, height, child) => SuperListView.builder(
controller: scrollController, listController: listController,
reverse: true, // Show newest messages at the bottom padding: EdgeInsets.only(
itemCount: messageList.length, top: 16,
findChildIndexCallback: (key) { bottom:
if (key is! ValueKey<String>) return null; MediaQuery.of(context).padding.bottom + 8 + height,
final messageId = key.value.substring(messageKeyPrefix.length); ),
final index = messageList.indexWhere( controller: scrollController,
(m) => (m.nonce ?? m.id) == messageId, reverse: true, // Show newest messages at the bottom
); itemCount: messageList.length,
// Return null for invalid indices to let SuperListView handle it properly findChildIndexCallback: (key) {
return index >= 0 ? index : null; if (key is! ValueKey<String>) return null;
}, final messageId = key.value.substring(
extentEstimation: (_, _) => 40, messageKeyPrefix.length,
itemBuilder: (context, index) { );
final message = messageList[index]; final index = messageList.indexWhere(
final nextMessage = (m) => (m.nonce ?? m.id) == messageId,
index < messageList.length - 1 ? messageList[index + 1] : null; );
final isLastInGroup = // Return null for invalid indices to let SuperListView handle it properly
nextMessage == null || return index >= 0 ? index : null;
nextMessage.senderId != message.senderId || },
nextMessage.createdAt extentEstimation: (_, _) => 40,
.difference(message.createdAt) itemBuilder: (context, index) {
.inMinutes final message = messageList[index];
.abs() > final nextMessage =
3; index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
// Use a stable animation key that doesn't change during message lifecycle // Use a stable animation key that doesn't change during message lifecycle
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}'); final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
final messageWidget = chatIdentity.when( final messageWidget = chatIdentity.when(
skipError: true, skipError: true,
data: data:
(identity) => GestureDetector( (identity) => GestureDetector(
onLongPress: () { onLongPress: () {
if (!isSelectionMode.value) { if (!isSelectionMode.value) {
toggleSelectionMode(); toggleSelectionMode();
toggleMessageSelection(message.id); toggleMessageSelection(message.id);
} }
}, },
onTap: () { onTap: () {
if (isSelectionMode.value) { if (isSelectionMode.value) {
toggleMessageSelection(message.id); toggleMessageSelection(message.id);
} }
}, },
child: Container( child: Container(
color: color:
selectedMessages.value.contains(message.id) selectedMessages.value.contains(message.id)
? Theme.of( ? Theme.of(context)
context, .colorScheme
).colorScheme.primaryContainer.withOpacity(0.3) .primaryContainer
: null, .withOpacity(0.3)
child: Stack( : null,
children: [ child: Stack(
MessageItem( children: [
key: settings.disableAnimation ? key : null, MessageItem(
message: message, key:
isCurrentUser: identity?.id == message.senderId, settings.disableAnimation
onAction: ? key
isSelectionMode.value : null,
? null message: message,
: (action) { isCurrentUser:
switch (action) { identity?.id == message.senderId,
case MessageItemAction.delete: onAction:
messagesNotifier.deleteMessage( isSelectionMode.value
message.id, ? null
); : (action) {
case MessageItemAction.edit: switch (action) {
messageEditingTo.value = case MessageItemAction.delete:
message.toRemoteMessage(); messagesNotifier
messageController.text = .deleteMessage(
messageEditingTo.value?.content ?? ''; message.id,
attachments.value = );
messageEditingTo.value!.attachments case MessageItemAction.edit:
.map( messageEditingTo.value =
(e) => message
UniversalFile.fromAttachment( .toRemoteMessage();
e, messageController.text =
), messageEditingTo
) .value
.toList(); ?.content ??
case MessageItemAction.forward: '';
messageForwardingTo.value = attachments.value =
message.toRemoteMessage(); messageEditingTo
case MessageItemAction.reply: .value!
messageReplyingTo.value = .attachments
message.toRemoteMessage(); .map(
case MessageItemAction.resend: (e) =>
messagesNotifier.retryMessage(message.id); UniversalFile.fromAttachment(
} e,
}, ),
onJump: (messageId) { )
scrollToMessage( .toList();
messageId: messageId, case MessageItemAction
messageList: messageList, .forward:
messagesNotifier: messagesNotifier, messageForwardingTo.value =
listController: listController, message
scrollController: scrollController, .toRemoteMessage();
ref: ref, case MessageItemAction.reply:
messageReplyingTo.value =
message
.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier
.retryMessage(
message.id,
);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
},
progress:
attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value
.contains(message.id),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
}
},
),
if (selectedMessages.value.contains(
message.id,
))
...([
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
]),
],
),
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(
opacity: animationValue,
child: child,
),
);
},
child: messageWidget,
); );
}, },
progress: attachmentProgress.value[message.id], ),
showAvatar: isLastInGroup, )
isSelectionMode: isSelectionMode.value, : SuperListView.builder(
isSelected: selectedMessages.value.contains(message.id), listController: listController,
onToggleSelection: toggleMessageSelection, padding: EdgeInsets.only(
onEnterSelectionMode: () { top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
// Return null for invalid indices to let SuperListView handle it properly
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
// Use a stable animation key that doesn't change during message lifecycle
final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) { if (!isSelectionMode.value) {
toggleSelectionMode(); toggleSelectionMode();
toggleMessageSelection(message.id);
} }
}, },
), onTap: () {
if (selectedMessages.value.contains(message.id)) if (isSelectionMode.value) {
Positioned( toggleMessageSelection(message.id);
top: 8, }
right: 8, },
child: Container( child: Container(
width: 16, color:
height: 16, selectedMessages.value.contains(message.id)
decoration: BoxDecoration( ? Theme.of(context)
color: Theme.of(context).colorScheme.primary, .colorScheme
shape: BoxShape.circle, .primaryContainer
), .withOpacity(0.3)
child: Icon( : null,
Icons.check, child: Stack(
size: 12, children: [
color: Theme.of(context).colorScheme.onPrimary, MessageItem(
), key: settings.disableAnimation ? key : null,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo
.value
?.content ??
'';
attachments.value =
messageEditingTo
.value!
.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(
message.id,
);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
}
},
),
if (selectedMessages.value.contains(message.id))
...([
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
]),
],
), ),
), ),
], ),
), loading:
), () => MessageItem(
), message: message,
loading: isCurrentUser: false,
() => MessageItem( onAction: null,
message: message, progress: null,
isCurrentUser: false, showAvatar: false,
onAction: null, onJump: (_) {},
progress: null, ),
showAvatar: false, error: (_, _) => const SizedBox.shrink(),
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(opacity: animationValue, child: child),
); );
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(opacity: animationValue, child: child),
);
},
child: messageWidget,
);
}, },
child: messageWidget,
); );
},
);
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(

View File

@@ -407,6 +407,9 @@ class ThoughtChatInterface extends HookConsumerWidget {
final inputKey = useMemoized(() => GlobalKey()); final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0); final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
final chatState = useThoughtChat( final chatState = useThoughtChat(
ref, ref,
initialSequenceId: initialSequenceId, initialSequenceId: initialSequenceId,
@@ -440,34 +443,86 @@ class ThoughtChatInterface extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: SuperListView.builder( child:
listController: chatState.listController, previousInputHeight != null &&
controller: chatState.scrollController, previousInputHeight != inputHeight.value
padding: EdgeInsets.only( ? TweenAnimationBuilder<double>(
top: 16, tween: Tween<double>(
bottom: begin: previousInputHeight,
MediaQuery.of(context).padding.bottom + end: inputHeight.value,
8 + ),
inputHeight.value, // Leave space for thought input duration: const Duration(milliseconds: 200),
), curve: Curves.easeOut,
reverse: true, builder:
itemCount: (context, height, child) =>
chatState.localThoughts.value.length + SuperListView.builder(
(chatState.isStreaming.value ? 1 : 0), listController: chatState.listController,
itemBuilder: (context, index) { controller: chatState.scrollController,
if (chatState.isStreaming.value && index == 0) { padding: EdgeInsets.only(
return ThoughtItem( top: 16,
isStreaming: true, bottom:
streamingItems: chatState.streamingItems.value, MediaQuery.of(
); context,
} ).padding.bottom +
final thoughtIndex = 8 +
chatState.isStreaming.value ? index - 1 : index; height,
final thought = ),
chatState.localThoughts.value[thoughtIndex]; reverse: true,
return ThoughtItem(thought: thought); itemCount:
}, chatState.localThoughts.value.length +
), (chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value &&
index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState
.localThoughts
.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
)
: SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value,
),
reverse: true,
itemCount:
chatState.localThoughts.value.length +
(chatState.isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought);
},
),
), ),
], ],
), ),