Добавлено управление анимацией раскрытия историй в экране списка чатов

This commit is contained in:
klockky
2026-04-03 20:28:10 +03:00
parent b5a415292f
commit 98f02395d5
+479 -324
View File
@@ -32,6 +32,13 @@ class _ChatListScreenState extends State<ChatListScreen>
final Set<String> _selectedChats = {}; final Set<String> _selectedChats = {};
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
double _pullRatio = 0.0; double _pullRatio = 0.0;
static const double _kStoriesPullTriggerPx = 16.0;
late AnimationController _storiesRevealController;
double _revealAnimBegin = 0.0;
double _closeAnimBegin = 0.0;
bool _storiesAnimClosing = false;
bool _storiesDockedOpen = false;
double _storiesCloseIntentAccum = 0.0;
ProfileData? _profile; ProfileData? _profile;
List<CachedChat> _chats = []; List<CachedChat> _chats = [];
SessionState _sessionState = SessionState.disconnected; SessionState _sessionState = SessionState.disconnected;
@@ -80,6 +87,14 @@ class _ChatListScreenState extends State<ChatListScreen>
duration: const Duration(milliseconds: 1500), duration: const Duration(milliseconds: 1500),
)..repeat(); )..repeat();
_storiesRevealController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
)
..addListener(_onStoriesRevealTick)
..addStatusListener(_onStoriesRevealStatus);
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_sessionState = api.state; _sessionState = api.state;
@@ -181,6 +196,110 @@ class _ChatListScreenState extends State<ChatListScreen>
); );
} }
void _onStoriesRevealTick() {
if (!mounted) return;
final t = Curves.easeOutCubic.transform(_storiesRevealController.value);
setState(() {
if (_storiesAnimClosing) {
_pullRatio = _closeAnimBegin * (1.0 - t);
} else {
_pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t;
}
});
}
void _onStoriesRevealStatus(AnimationStatus status) {
if (!mounted) return;
if (status == AnimationStatus.completed) {
setState(() {
if (_storiesAnimClosing) {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
} else {
_pullRatio = 1.0;
_storiesDockedOpen = true;
}
});
}
}
void _startStoriesAutoReveal(double suggestedFrom) {
if (_storiesRevealController.isAnimating && !_storiesAnimClosing) return;
if (_storiesDockedOpen) return;
_storiesRevealController.stop();
_storiesAnimClosing = false;
final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0));
if (from >= 1.0) {
setState(() {
_pullRatio = 1.0;
_storiesDockedOpen = true;
});
return;
}
_revealAnimBegin = from;
_storiesRevealController.duration = Duration(
milliseconds: (260 + 240 * (1.0 - from)).round(),
);
_storiesRevealController.reset();
_storiesRevealController.forward(from: 0);
}
void _startStoriesAutoClose() {
if (_pullRatio <= 0 &&
!_storiesDockedOpen &&
!_storiesRevealController.isAnimating) {
return;
}
if (_storiesAnimClosing && _storiesRevealController.isAnimating) return;
_storiesCloseIntentAccum = 0.0;
_storiesRevealController.stop();
_storiesAnimClosing = true;
final from = _pullRatio.clamp(0.0, 1.0);
if (from <= 0) {
setState(() {
_pullRatio = 0.0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
});
return;
}
_closeAnimBegin = from;
_storiesRevealController.duration = Duration(
milliseconds: (260 + 240 * from).round(),
);
_storiesRevealController.reset();
_storiesRevealController.forward(from: 0);
}
bool _onStoriesScrollNotification(ScrollNotification n) {
if (_currentNavIndex != 0) return false;
if (n is! ScrollUpdateNotification) return false;
if (!_scrollController.hasClients) return false;
if (!_storiesDockedOpen || _storiesRevealController.isAnimating) {
_storiesCloseIntentAccum = 0.0;
return false;
}
final m = n.metrics;
if (m.axis != Axis.vertical) return false;
if (m.pixels > m.minScrollExtent + 1.0) {
_storiesCloseIntentAccum = 0.0;
return false;
}
final d = n.scrollDelta;
if (d == null) return false;
if (d > 0) {
_storiesCloseIntentAccum = 0.0;
return false;
}
_storiesCloseIntentAccum += -d;
if (_storiesCloseIntentAccum >= _kStoriesPullTriggerPx) {
_storiesCloseIntentAccum = 0.0;
_startStoriesAutoClose();
}
return false;
}
void _onScroll() { void _onScroll() {
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
final double offset = _scrollController.offset; final double offset = _scrollController.offset;
@@ -191,16 +310,28 @@ class _ChatListScreenState extends State<ChatListScreen>
} }
if (offset < 0) { if (offset < 0) {
final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0);
if (newRatio != _pullRatio) { if (_storiesRevealController.isAnimating) {
return;
}
if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) {
_startStoriesAutoReveal(dragRatio);
} else if (!_storiesDockedOpen) {
if (dragRatio != _pullRatio) {
setState(() {
_pullRatio = dragRatio;
});
}
}
} else {
if (_storiesDockedOpen || _storiesRevealController.isAnimating) {
return;
}
if (_pullRatio > 0) {
setState(() { setState(() {
_pullRatio = newRatio; _pullRatio = 0.0;
}); });
} }
} else if (_pullRatio > 0) {
setState(() {
_pullRatio = 0.0;
});
} }
} }
} }
@@ -209,6 +340,10 @@ class _ChatListScreenState extends State<ChatListScreen>
void dispose() { void dispose() {
_stateSub?.cancel(); _stateSub?.cancel();
_fabController.dispose(); _fabController.dispose();
_storiesRevealController
..removeListener(_onStoriesRevealTick)
..removeStatusListener(_onStoriesRevealStatus)
..dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -242,328 +377,340 @@ class _ChatListScreenState extends State<ChatListScreen>
if (_scrollController.hasClients && if (_scrollController.hasClients &&
_scrollController.offset <= 0) { _scrollController.offset <= 0) {
if (pointerSignal.scrollDelta.dy < 0) { if (pointerSignal.scrollDelta.dy < 0) {
// Scrolled UP (pulling down) _startStoriesAutoReveal(max(_pullRatio, 0.18));
setState(() {
_pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0);
});
} else if (pointerSignal.scrollDelta.dy > 0 && } else if (pointerSignal.scrollDelta.dy > 0 &&
_pullRatio > 0) { _pullRatio > 0) {
// Scrolled DOWN (folding up) _startStoriesAutoClose();
setState(() {
_pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0);
});
} }
} }
} }
}, },
child: CustomScrollView( child: NotificationListener<ScrollNotification>(
controller: _scrollController, onNotification: _onStoriesScrollNotification,
physics: const BouncingScrollPhysics( child: CustomScrollView(
parent: AlwaysScrollableScrollPhysics(), controller: _scrollController,
), physics: const BouncingScrollPhysics(
slivers: [ parent: AlwaysScrollableScrollPhysics(),
SliverToBoxAdapter( ),
child: AnimatedContainer( slivers: [
duration: const Duration(milliseconds: 200), SliverToBoxAdapter(
curve: Curves.easeOutCubic, child: ClipRect(
height: _shouldCollapseSearch clipBehavior: Clip.hardEdge,
? 0 child: AnimatedSize(
: (100 + (96 * _pullRatio)), duration: const Duration(milliseconds: 200),
color: Colors.transparent, curve: Curves.easeOutCubic,
clipBehavior: Clip.hardEdge, alignment: Alignment.topCenter,
child: AnimatedContainer( child: _shouldCollapseSearch
duration: const Duration(milliseconds: 300), ? const SizedBox(
curve: Curves.easeOutCubic, width: double.infinity,
transform: Matrix4.translationValues( height: 0,
0, )
_shouldCollapseSearch ? -100 : 0, : Column(
0, mainAxisSize: MainAxisSize.min,
), crossAxisAlignment:
child: Column( CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
20, 20,
12, 12,
20, 20,
2,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (_pullRatio < 0.8)
Opacity(
opacity: 1.0 - _pullRatio,
child: Container(
width: 50 * (1.0 - _pullRatio),
height: 32,
margin: const EdgeInsets.only(
right: 8,
),
child: Stack(
children: [
_buildFoldedStory(
'https://i.pravatar.cc/150?u=dasha',
0,
),
_buildFoldedStory(
'https://i.pravatar.cc/150?u=mastika',
1,
),
_buildFoldedStory(
'https://i.pravatar.cc/150?u=stas',
2,
),
],
),
),
),
Text(
_sessionState == SessionState.online
? (_profile?.firstName ?? 'Чат')
: 'Подключение...',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
],
),
PopupMenuButton<int>(
icon: Icon(
Symbols.more_vert,
color: cs.outline,
weight: 400,
),
offset: const Offset(0, 48),
elevation: 4,
color: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
16,
),
),
itemBuilder: (context) => [
_buildPopupMenuItem(
1,
'Кнопка 1',
Symbols.settings,
),
_buildPopupMenuItem(
2, 2,
'Кнопка 2',
Symbols.notifications,
), ),
_buildPopupMenuItem( child: Row(
3, mainAxisAlignment:
'Кнопка 3', MainAxisAlignment.spaceBetween,
Symbols.shield, children: [
Row(
children: [
if (_pullRatio < 0.8)
Opacity(
opacity: 1.0 - _pullRatio,
child: Container(
width:
50 *
(1.0 - _pullRatio),
height: 32,
margin:
const EdgeInsets.only(
right: 8,
),
child: Stack(
children: [
_buildFoldedStory(
'https://i.pravatar.cc/150?u=dasha',
0,
),
_buildFoldedStory(
'https://i.pravatar.cc/150?u=mastika',
1,
),
_buildFoldedStory(
'https://i.pravatar.cc/150?u=stas',
2,
),
],
),
),
),
Text(
_sessionState ==
SessionState.online
? (_profile
?.firstName ??
'Чат')
: 'Подключение...',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight:
FontWeight.w600,
fontFamily: 'Outfit',
),
),
],
),
PopupMenuButton<int>(
icon: Icon(
Symbols.more_vert,
color: cs.outline,
weight: 400,
),
offset: const Offset(0, 48),
elevation: 4,
color: cs.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(16),
),
itemBuilder: (context) => [
_buildPopupMenuItem(
1,
'Кнопка 1',
Symbols.settings,
),
_buildPopupMenuItem(
2,
'Кнопка 2',
Symbols.notifications,
),
_buildPopupMenuItem(
3,
'Кнопка 3',
Symbols.shield,
),
_buildPopupMenuItem(
4,
'Кнопка 4',
Symbols.info,
),
],
),
],
), ),
_buildPopupMenuItem( ),
4, SizedBox(
'Кнопка 4', height: 96 * _pullRatio,
Symbols.info, child: Opacity(
opacity: _pullRatio,
child: ListView(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(
horizontal: 20,
),
children: [
_buildStoryItem(
'Даша',
'https://i.pravatar.cc/150?u=dasha',
true,
),
_buildStoryItem(
'Мастика',
'https://i.pravatar.cc/150?u=mastika',
false,
),
],
),
), ),
],
),
],
),
),
SizedBox(
height: 96 * _pullRatio,
child: Opacity(
opacity: _pullRatio,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
children: [
_buildStoryItem(
'Даша',
'https://i.pravatar.cc/150?u=dasha',
true,
), ),
_buildStoryItem( if (_showCacheWarning)
'Мастика', Padding(
'https://i.pravatar.cc/150?u=mastika', padding: const EdgeInsets.fromLTRB(
false, 20,
), 0,
], 20,
), 8,
), ),
), child: Container(
if (_showCacheWarning) padding:
Padding( const EdgeInsets.symmetric(
padding: const EdgeInsets.fromLTRB( horizontal: 16,
20, vertical: 8,
0, ),
20, decoration: BoxDecoration(
8, color: cs.errorContainer
), .withOpacity(0.3),
child: Container( borderRadius:
padding: const EdgeInsets.symmetric( BorderRadius.circular(12),
horizontal: 16, border: Border.all(
vertical: 8, color: cs.error.withOpacity(
), 0.2,
decoration: BoxDecoration( ),
color: cs.errorContainer.withOpacity( ),
0.3, ),
), child: Row(
borderRadius: BorderRadius.circular(12), children: [
border: Border.all( Icon(
color: cs.error.withOpacity(0.2), Symbols.cloud_off,
), size: 18,
), color: cs.error,
child: Row( ),
children: [ const SizedBox(width: 12),
Icon( const Expanded(
Symbols.cloud_off, child: Text(
size: 18, 'Ошибка соединения, сейчас вы смотрите КЕШ',
color: cs.error, style: TextStyle(
), fontSize: 12,
const SizedBox(width: 12), fontWeight:
const Expanded( FontWeight.w500,
child: Text( ),
'Ошибка соединения, сейчас вы смотрите КЕШ', ),
style: TextStyle( ),
fontSize: 12, ],
fontWeight: FontWeight.w500,
), ),
), ),
), ),
], Padding(
), padding: const EdgeInsets.fromLTRB(
), 20,
), 2,
Padding( 20,
padding: const EdgeInsets.fromLTRB( 8,
20, ),
2, child: Container(
20, height: 44,
8, decoration: BoxDecoration(
), color: cs.surfaceContainerHighest,
child: Container( borderRadius:
height: 44, BorderRadius.circular(50),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(50),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Row(
children: [
Icon(
Symbols.search,
color: cs.outline,
size: 20,
weight: 400,
),
const SizedBox(width: 10),
Expanded(
child: TextField(
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
), ),
decoration: InputDecoration( padding: const EdgeInsets.symmetric(
hintText: 'Поиск', horizontal: 16,
hintStyle: TextStyle( ),
color: cs.outline, child: Row(
fontSize: 15, children: [
), Icon(
border: InputBorder.none, Symbols.search,
isDense: true, color: cs.outline,
contentPadding: EdgeInsets.zero, size: 20,
weight: 400,
),
const SizedBox(width: 10),
Expanded(
child: TextField(
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
),
decoration: InputDecoration(
hintText: 'Поиск',
hintStyle: TextStyle(
color: cs.outline,
fontSize: 15,
),
border: InputBorder.none,
isDense: true,
contentPadding:
EdgeInsets.zero,
),
),
),
],
), ),
), ),
), ),
], ],
), ),
),
),
],
), ),
), ),
), ),
), SliverPadding(
SliverPadding( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, sliver: SliverToBoxAdapter(
sliver: SliverToBoxAdapter( child: AnimatedContainer(
child: AnimatedContainer( duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic,
curve: Curves.easeOutCubic, height: 48,
height: 48, child: ScrollConfiguration(
child: ScrollConfiguration( behavior: ScrollConfiguration.of(context)
behavior: ScrollConfiguration.of(context) .copyWith(
.copyWith( dragDevices: {
dragDevices: { ui.PointerDeviceKind.touch,
ui.PointerDeviceKind.touch, ui.PointerDeviceKind.mouse,
ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.trackpad,
ui.PointerDeviceKind.trackpad, },
}, ),
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
), ),
child: ListView( physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal, children: [
padding: const EdgeInsets.symmetric( _buildFolderChip('Все чаты'),
horizontal: 20, const SizedBox(width: 8),
vertical: 4, _buildFolderChip('Контакты'),
const SizedBox(width: 8),
_buildFolderChip('Пидоры'),
const SizedBox(width: 8),
_buildFolderChip('Каналы'),
const SizedBox(width: 8),
_buildFolderChip('Группы'),
const SizedBox(width: 8),
_buildFolderChip('Боты'),
const SizedBox(width: 8),
_buildFolderChip('Избранное'),
const SizedBox(width: 8),
_buildFolderChip('Архив'),
],
), ),
physics: const BouncingScrollPhysics(),
children: [
_buildFolderChip('Все чаты'),
const SizedBox(width: 8),
_buildFolderChip('Контакты'),
const SizedBox(width: 8),
_buildFolderChip('Пидоры'),
const SizedBox(width: 8),
_buildFolderChip('Каналы'),
const SizedBox(width: 8),
_buildFolderChip('Группы'),
const SizedBox(width: 8),
_buildFolderChip('Боты'),
const SizedBox(width: 8),
_buildFolderChip('Избранное'),
const SizedBox(width: 8),
_buildFolderChip('Архив'),
],
), ),
), ),
), ),
), ),
), SliverList(
SliverList( delegate: SliverChildBuilderDelegate(
delegate: SliverChildBuilderDelegate((context, index) { (context, index) {
if (_isInitialLoading) { if (_isInitialLoading) {
return _buildChatShimmer(); return _buildChatShimmer();
} }
final chat = _chats[index]; final chat = _chats[index];
return _buildChatItem( return _buildChatItem(
chat.id.toString(), chat.id.toString(),
chat.title ?? 'Чат', chat.title ?? 'Чат',
chat.lastMsgText ?? '', chat.lastMsgText ?? '',
_formatTime(chat.lastMsgTime), _formatTime(chat.lastMsgTime),
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty) (chat.iconUrl != null &&
? chat.iconUrl! chat.iconUrl!.isNotEmpty)
: '', ? chat.iconUrl!
isOnline: chat.isOnline, : '',
unreadCount: chat.unreadCount, isOnline: chat.isOnline,
isMuted: chat.dontDisturbUntil > 0, unreadCount: chat.unreadCount,
); isMuted: chat.dontDisturbUntil > 0,
}, childCount: _isInitialLoading ? 10 : _chats.length), );
), },
const SliverPadding( childCount: _isInitialLoading ? 10 : _chats.length,
padding: EdgeInsets.only(bottom: 100), ),
), ),
], const SliverPadding(
), // CustomScrollView padding: EdgeInsets.only(bottom: 100),
), // Listener ),
],
),
),
),
const CallsTab(), const CallsTab(),
const ContactsTab(), const ContactsTab(),
const SettingsTab(), const SettingsTab(),
@@ -785,31 +932,39 @@ class _ChatListScreenState extends State<ChatListScreen>
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: Column( child: SizedBox(
children: [ width: 68,
Container( child: FittedBox(
padding: const EdgeInsets.all(2.5), fit: BoxFit.scaleDown,
decoration: BoxDecoration( alignment: Alignment.topCenter,
shape: BoxShape.circle, child: Column(
border: hasUpdate mainAxisSize: MainAxisSize.min,
? Border.all(color: cs.primary, width: 2) children: [
: Border.all(color: cs.outlineVariant), Container(
), padding: const EdgeInsets.all(2.5),
child: CircleAvatar( decoration: BoxDecoration(
radius: 26, shape: BoxShape.circle,
backgroundImage: NetworkImage(imageUrl), border: hasUpdate
), ? Border.all(color: cs.primary, width: 2)
: Border.all(color: cs.outlineVariant),
),
child: CircleAvatar(
radius: 26,
backgroundImage: NetworkImage(imageUrl),
),
),
const SizedBox(height: 6),
Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
), ),
const SizedBox(height: 6), ),
Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
), ),
); );
} }