💫 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,23 +614,31 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
} }
Widget chatMessageListWidget( Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
List<LocalChatMessage> messageList, previousInputHeight != null && previousInputHeight != inputHeight.value
) => SuperListView.builder( ? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) => SuperListView.builder(
listController: listController, listController: listController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, top: 16,
bottom: bottom:
MediaQuery.of(context).padding.bottom + MediaQuery.of(context).padding.bottom + 8 + height,
8 +
inputHeight.value, // Leave space for chat input
), ),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
findChildIndexCallback: (key) { findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null; if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length); final messageId = key.value.substring(
messageKeyPrefix.length,
);
final index = messageList.indexWhere( final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId, (m) => (m.nonce ?? m.id) == messageId,
); );
@@ -638,7 +649,9 @@ class ChatRoomScreen extends HookConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messageList[index]; final message = messageList[index];
final nextMessage = final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null; index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup = final isLastInGroup =
nextMessage == null || nextMessage == null ||
nextMessage.senderId != message.senderId || nextMessage.senderId != message.senderId ||
@@ -649,7 +662,9 @@ class ChatRoomScreen extends HookConsumerWidget {
3; 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,
@@ -669,9 +684,224 @@ class ChatRoomScreen extends HookConsumerWidget {
child: Container( child: Container(
color: color:
selectedMessages.value.contains(message.id) selectedMessages.value.contains(message.id)
? Theme.of( ? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null,
child: Stack(
children: [
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, context,
).colorScheme.primaryContainer.withOpacity(0.3) ).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,
);
},
),
)
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
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) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null, : null,
child: Stack( child: Stack(
children: [ children: [
@@ -692,9 +922,14 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = messageEditingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
messageController.text = messageController.text =
messageEditingTo.value?.content ?? ''; messageEditingTo
.value
?.content ??
'';
attachments.value = attachments.value =
messageEditingTo.value!.attachments messageEditingTo
.value!
.attachments
.map( .map(
(e) => (e) =>
UniversalFile.fromAttachment( UniversalFile.fromAttachment(
@@ -709,7 +944,9 @@ class ChatRoomScreen extends HookConsumerWidget {
messageReplyingTo.value = messageReplyingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
case MessageItemAction.resend: case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id); messagesNotifier.retryMessage(
message.id,
);
} }
}, },
onJump: (messageId) { onJump: (messageId) {
@@ -725,7 +962,9 @@ class ChatRoomScreen extends HookConsumerWidget {
progress: attachmentProgress.value[message.id], progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup, showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value, isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(message.id), isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection, onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () { onEnterSelectionMode: () {
if (!isSelectionMode.value) { if (!isSelectionMode.value) {
@@ -734,6 +973,7 @@ class ChatRoomScreen extends HookConsumerWidget {
}, },
), ),
if (selectedMessages.value.contains(message.id)) if (selectedMessages.value.contains(message.id))
...([
Positioned( Positioned(
top: 8, top: 8,
right: 8, right: 8,
@@ -741,16 +981,23 @@ class ChatRoomScreen extends HookConsumerWidget {
width: 16, width: 16,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary, color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
Icons.check, Icons.check,
size: 12, size: 12,
color: Theme.of(context).colorScheme.onPrimary, color:
Theme.of(
context,
).colorScheme.onPrimary,
), ),
), ),
), ),
]),
], ],
), ),
), ),

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,7 +443,56 @@ class ThoughtChatInterface extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: SuperListView.builder( child:
previousInputHeight != null &&
previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) =>
SuperListView.builder(
listController: chatState.listController,
controller: chatState.scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(
context,
).padding.bottom +
8 +
height,
),
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);
},
),
)
: SuperListView.builder(
listController: chatState.listController, listController: chatState.listController,
controller: chatState.scrollController, controller: chatState.scrollController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
@@ -448,7 +500,7 @@ class ThoughtChatInterface extends HookConsumerWidget {
bottom: bottom:
MediaQuery.of(context).padding.bottom + MediaQuery.of(context).padding.bottom +
8 + 8 +
inputHeight.value, // Leave space for thought input inputHeight.value,
), ),
reverse: true, reverse: true,
itemCount: itemCount:
@@ -458,11 +510,14 @@ class ThoughtChatInterface extends HookConsumerWidget {
if (chatState.isStreaming.value && index == 0) { if (chatState.isStreaming.value && index == 0) {
return ThoughtItem( return ThoughtItem(
isStreaming: true, isStreaming: true,
streamingItems: chatState.streamingItems.value, streamingItems:
chatState.streamingItems.value,
); );
} }
final thoughtIndex = final thoughtIndex =
chatState.isStreaming.value ? index - 1 : index; chatState.isStreaming.value
? index - 1
: index;
final thought = final thought =
chatState.localThoughts.value[thoughtIndex]; chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought); return ThoughtItem(thought: thought);