💫 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 inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
// Periodic height measurement for dynamic sizing
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
@@ -611,23 +614,31 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
Widget chatMessageListWidget(
List<LocalChatMessage> messageList,
) => SuperListView.builder(
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
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: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value, // Leave space for chat input
MediaQuery.of(context).padding.bottom + 8 + height,
),
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 messageId = key.value.substring(
messageKeyPrefix.length,
);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
@@ -638,7 +649,9 @@ class ChatRoomScreen extends HookConsumerWidget {
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null;
index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
@@ -649,7 +662,9 @@ class ChatRoomScreen extends HookConsumerWidget {
3;
// 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(
skipError: true,
@@ -669,9 +684,224 @@ class ChatRoomScreen extends HookConsumerWidget {
child: Container(
color:
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,
).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,
child: Stack(
children: [
@@ -692,9 +922,14 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
messageEditingTo
.value
?.content ??
'';
attachments.value =
messageEditingTo.value!.attachments
messageEditingTo
.value!
.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
@@ -709,7 +944,9 @@ class ChatRoomScreen extends HookConsumerWidget {
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
messagesNotifier.retryMessage(
message.id,
);
}
},
onJump: (messageId) {
@@ -725,7 +962,9 @@ class ChatRoomScreen extends HookConsumerWidget {
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(message.id),
isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
@@ -734,6 +973,7 @@ class ChatRoomScreen extends HookConsumerWidget {
},
),
if (selectedMessages.value.contains(message.id))
...([
Positioned(
top: 8,
right: 8,
@@ -741,16 +981,23 @@ class ChatRoomScreen extends HookConsumerWidget {
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
color:
Theme.of(
context,
).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
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 inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
final chatState = useThoughtChat(
ref,
initialSequenceId: initialSequenceId,
@@ -440,7 +443,56 @@ class ThoughtChatInterface extends HookConsumerWidget {
child: Column(
children: [
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,
controller: chatState.scrollController,
padding: EdgeInsets.only(
@@ -448,7 +500,7 @@ class ThoughtChatInterface extends HookConsumerWidget {
bottom:
MediaQuery.of(context).padding.bottom +
8 +
inputHeight.value, // Leave space for thought input
inputHeight.value,
),
reverse: true,
itemCount:
@@ -458,11 +510,14 @@ class ThoughtChatInterface extends HookConsumerWidget {
if (chatState.isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingItems: chatState.streamingItems.value,
streamingItems:
chatState.streamingItems.value,
);
}
final thoughtIndex =
chatState.isStreaming.value ? index - 1 : index;
chatState.isStreaming.value
? index - 1
: index;
final thought =
chatState.localThoughts.value[thoughtIndex];
return ThoughtItem(thought: thought);