more fab anim

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-06-04 10:54:58 +08:00
parent dec059b780
commit 0483096f93
9 changed files with 327 additions and 194 deletions

View File

@@ -22,7 +22,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
abstract class CommonDynPageState<T extends StatefulWidget> extends State<T> abstract class CommonDynPageState<T extends StatefulWidget> extends State<T>
with SingleTickerProviderStateMixin, FabMixin { with SingleTickerProviderStateMixin, BaseFabMixin, FabMixin {
CommonDynController get controller; CommonDynController get controller;
late final ScrollController scrollController; late final ScrollController scrollController;

View File

@@ -1,18 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
mixin FabMixin<T extends StatefulWidget> on State<T>, TickerProvider { mixin BaseFabMixin<T extends StatefulWidget> on State<T>, TickerProvider {
bool _isFabVisible = true; late bool _isFabVisible = true;
late final AnimationController _fabAnimationCtr; AnimationController get fabAnimationCtr;
late final Animation<Offset> fabAnimation; Animation<Offset> get fabAnimation;
@override AnimationController _initController() {
void initState() { return AnimationController(
super.initState();
_fabAnimationCtr = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
); );
fabAnimation = _fabAnimationCtr.drive( }
Animation<Offset> _initAnimation() {
return fabAnimationCtr.drive(
Tween<Offset>( Tween<Offset>(
begin: Offset.zero, begin: Offset.zero,
end: const Offset(0.0, 1.0), end: const Offset(0.0, 1.0),
@@ -23,20 +24,51 @@ mixin FabMixin<T extends StatefulWidget> on State<T>, TickerProvider {
void showFab() { void showFab() {
if (!_isFabVisible) { if (!_isFabVisible) {
_isFabVisible = true; _isFabVisible = true;
_fabAnimationCtr.reverse(); fabAnimationCtr.reverse();
} }
} }
void hideFab() { void hideFab() {
if (_isFabVisible) { if (_isFabVisible) {
_isFabVisible = false; _isFabVisible = false;
_fabAnimationCtr.forward(); fabAnimationCtr.forward();
} }
} }
}
mixin FabMixin<T extends StatefulWidget> on BaseFabMixin<T> {
@override
late final AnimationController fabAnimationCtr;
@override
late final Animation<Offset> fabAnimation;
@override
void initState() {
super.initState();
fabAnimationCtr = _initController();
fabAnimation = _initAnimation();
}
@override @override
void dispose() { void dispose() {
_fabAnimationCtr.dispose(); fabAnimationCtr.dispose();
super.dispose();
}
}
mixin LazyFabMixin<T extends StatefulWidget> on BaseFabMixin<T> {
AnimationController? _fabAnimationCtr;
Animation<Offset>? _fabAnimation;
@override
AnimationController get fabAnimationCtr =>
_fabAnimationCtr ??= _initController();
@override
Animation<Offset> get fabAnimation => _fabAnimation ??= _initAnimation();
@override
void dispose() {
_fabAnimationCtr?.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@@ -9,6 +9,7 @@ import 'package:PiliPlus/common/widgets/sliver/sliver_pinned_header.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/item.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/item.dart';
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart';
import 'package:PiliPlus/pages/common/fab_mixin.dart';
import 'package:PiliPlus/pages/dynamics/widgets/dynamic_panel.dart'; import 'package:PiliPlus/pages/dynamics/widgets/dynamic_panel.dart';
import 'package:PiliPlus/pages/dynamics_create/view.dart'; import 'package:PiliPlus/pages/dynamics_create/view.dart';
import 'package:PiliPlus/pages/dynamics_topic/controller.dart'; import 'package:PiliPlus/pages/dynamics_topic/controller.dart';
@@ -35,7 +36,8 @@ class DynTopicPage extends StatefulWidget {
State<DynTopicPage> createState() => _DynTopicPageState(); State<DynTopicPage> createState() => _DynTopicPageState();
} }
class _DynTopicPageState extends State<DynTopicPage> with DynMixin { class _DynTopicPageState extends State<DynTopicPage>
with DynMixin, SingleTickerProviderStateMixin, BaseFabMixin, FabMixin {
final DynTopicController _controller = Get.put( final DynTopicController _controller = Get.put(
DynTopicController(), DynTopicController(),
tag: Utils.generateRandomString(8), tag: Utils.generateRandomString(8),
@@ -51,102 +53,121 @@ class _DynTopicPageState extends State<DynTopicPage> with DynMixin {
children: [ children: [
refreshIndicator( refreshIndicator(
onRefresh: _controller.onRefresh, onRefresh: _controller.onRefresh,
child: CustomScrollView( child: NotificationListener<UserScrollNotification>(
controller: _controller.scrollController, onNotification: (notification) {
physics: const AlwaysScrollableScrollPhysics(), final direction = notification.direction;
slivers: [ if (direction == .forward) {
Obx( showFab();
() => _buildAppBar( } else if (direction == .reverse) {
colorScheme, hideFab();
padding, }
_controller.topState.value, return false;
},
child: CustomScrollView(
controller: _controller.scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
Obx(
() => _buildAppBar(
colorScheme,
padding,
_controller.topState.value,
),
), ),
), Obx(() {
Obx(() { final allSortBy =
final allSortBy = _controller.topicSortByConf.value?.allSortBy;
_controller.topicSortByConf.value?.allSortBy; if (allSortBy != null && allSortBy.isNotEmpty) {
if (allSortBy != null && allSortBy.isNotEmpty) { return SliverPinnedHeader(
return SliverPinnedHeader( backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, child: Padding(
child: Padding( padding: EdgeInsets.only(
padding: EdgeInsets.only( left: 12 + padding.left,
left: 12 + padding.left, top: 6,
top: 6, bottom: 6,
bottom: 6, ),
child: Builder(
builder: (context) {
return ToggleButtons(
fillColor: colorScheme.secondaryContainer,
selectedColor: colorScheme.onSecondaryContainer,
constraints: const BoxConstraints(
minWidth: 54,
minHeight: 24,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
borderRadius: const .all(.circular(25)),
onPressed: (index) {
_controller.onSort(allSortBy[index].sortBy!);
(context as Element).markNeedsBuild();
},
isSelected: allSortBy
.map((e) => e.sortBy == _controller.sortBy)
.toList(),
children: allSortBy.map((e) {
return Text(
e.sortName!,
style: const TextStyle(
fontSize: 13,
height: 1,
),
strutStyle: const StrutStyle(
height: 1,
leading: 0,
fontSize: 13,
),
textScaler: TextScaler.noScaling,
);
}).toList(),
);
},
),
), ),
child: Builder( );
builder: (context) { }
return ToggleButtons( return const SliverToBoxAdapter();
fillColor: colorScheme.secondaryContainer, }),
selectedColor: colorScheme.onSecondaryContainer, SliverPadding(
constraints: const BoxConstraints( padding: EdgeInsets.only(
minWidth: 54, left: padding.left,
minHeight: 24, right: padding.right,
), bottom: padding.bottom + 100,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, ),
borderRadius: const .all(.circular(25)), sliver: buildPage(
onPressed: (index) { Obx(() => _buildBody(_controller.loadingState.value)),
_controller.onSort(allSortBy[index].sortBy!); ),
(context as Element).markNeedsBuild();
},
isSelected: allSortBy
.map((e) => e.sortBy == _controller.sortBy)
.toList(),
children: allSortBy.map((e) {
return Text(
e.sortName!,
style: const TextStyle(
fontSize: 13,
height: 1,
),
strutStyle: const StrutStyle(
height: 1,
leading: 0,
fontSize: 13,
),
textScaler: TextScaler.noScaling,
);
}).toList(),
);
},
),
),
);
}
return const SliverToBoxAdapter();
}),
SliverPadding(
padding: EdgeInsets.only(
left: padding.left,
right: padding.right,
bottom: padding.bottom + 100,
), ),
sliver: buildPage( ],
Obx(() => _buildBody(_controller.loadingState.value)), ),
),
),
],
), ),
), ),
Positioned( Positioned(
right: padding.right + kFloatingActionButtonMargin, right: padding.right + kFloatingActionButtonMargin,
bottom: padding.bottom + kFloatingActionButtonMargin, bottom: 0,
child: FloatingActionButton.extended( child: SlideTransition(
onPressed: () { position: fabAnimation,
if (_controller.isLogin) { child: Padding(
CreateDynPanel.onCreateDyn( padding: .only(
context, bottom: padding.bottom + kFloatingActionButtonMargin,
topic: Pair( ),
first: int.parse(_controller.topicId), child: FloatingActionButton.extended(
second: _controller.topicName, onPressed: () {
), if (_controller.isLogin) {
); CreateDynPanel.onCreateDyn(
} else { context,
SmartDialog.showToast('账号未登录'); topic: Pair(
} first: int.parse(_controller.topicId),
}, second: _controller.topicName,
icon: const Icon(CustomIcons.topic_tag, size: 20), ),
label: const Text('参与话题'), );
} else {
SmartDialog.showToast('账号未登录');
}
},
icon: const Icon(CustomIcons.topic_tag, size: 20),
label: const Text('参与话题'),
),
),
), ),
), ),
], ],

View File

@@ -7,6 +7,7 @@ import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/follow_order_type.dart'; import 'package:PiliPlus/models/common/follow_order_type.dart';
import 'package:PiliPlus/models_new/follow/list.dart'; import 'package:PiliPlus/models_new/follow/list.dart';
import 'package:PiliPlus/pages/common/fab_mixin.dart';
import 'package:PiliPlus/pages/follow/child/child_controller.dart'; import 'package:PiliPlus/pages/follow/child/child_controller.dart';
import 'package:PiliPlus/pages/follow/controller.dart'; import 'package:PiliPlus/pages/follow/controller.dart';
import 'package:PiliPlus/pages/follow/widgets/follow_item.dart'; import 'package:PiliPlus/pages/follow/widgets/follow_item.dart';
@@ -37,7 +38,11 @@ class FollowChildPage extends StatefulWidget {
} }
class _FollowChildPageState extends State<FollowChildPage> class _FollowChildPageState extends State<FollowChildPage>
with AutomaticKeepAliveClientMixin { with
AutomaticKeepAliveClientMixin,
SingleTickerProviderStateMixin,
BaseFabMixin,
LazyFabMixin {
late String _tag; late String _tag;
late FollowChildController _followController; late FollowChildController _followController;
@@ -107,20 +112,41 @@ class _FollowChildPageState extends State<FollowChildPage>
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
child, NotificationListener<UserScrollNotification>(
onNotification: (notification) {
final direction = notification.direction;
if (direction == .forward) {
showFab();
} else if (direction == .reverse) {
hideFab();
}
return false;
},
child: child,
),
Positioned( Positioned(
right: kFloatingActionButtonMargin + padding.right, right: kFloatingActionButtonMargin + padding.right,
bottom: kFloatingActionButtonMargin + padding.bottom, bottom: 0,
child: FloatingActionButton.extended( child: SlideTransition(
onPressed: () => _followController position: fabAnimation,
..setOrderType( child: Padding(
_followController.orderType.value == FollowOrderType.def padding: .only(
? FollowOrderType.attention bottom: kFloatingActionButtonMargin + padding.bottom,
: FollowOrderType.def, ),
) child: FloatingActionButton.extended(
..onReload(), onPressed: () => _followController
icon: const Icon(Icons.format_list_bulleted, size: 20), ..setOrderType(
label: Obx(() => Text(_followController.orderType.value.title)), _followController.orderType.value == FollowOrderType.def
? FollowOrderType.attention
: FollowOrderType.def,
)
..onReload(),
icon: const Icon(Icons.format_list_bulleted, size: 20),
label: Obx(
() => Text(_followController.orderType.value.title),
),
),
),
), ),
), ),
], ],

View File

@@ -40,7 +40,7 @@ class MainReplyPage extends StatefulWidget {
} }
class _MainReplyPageState extends State<MainReplyPage> class _MainReplyPageState extends State<MainReplyPage>
with SingleTickerProviderStateMixin, FabMixin { with SingleTickerProviderStateMixin, BaseFabMixin, FabMixin {
final _controller = Get.put( final _controller = Get.put(
MainReplyController(), MainReplyController(),
tag: Utils.generateRandomString(8), tag: Utils.generateRandomString(8),

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart';
import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models_new/space/space_opus/item.dart'; import 'package:PiliPlus/models_new/space/space_opus/item.dart';
import 'package:PiliPlus/pages/common/fab_mixin.dart';
import 'package:PiliPlus/pages/member_opus/controller.dart'; import 'package:PiliPlus/pages/member_opus/controller.dart';
import 'package:PiliPlus/pages/member_opus/widgets/space_opus_item.dart'; import 'package:PiliPlus/pages/member_opus/widgets/space_opus_item.dart';
import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/grid.dart';
@@ -30,7 +31,11 @@ class MemberOpus extends StatefulWidget {
} }
class _MemberOpusState extends State<MemberOpus> class _MemberOpusState extends State<MemberOpus>
with AutomaticKeepAliveClientMixin { with
AutomaticKeepAliveClientMixin,
SingleTickerProviderStateMixin,
BaseFabMixin,
LazyFabMixin {
late final MemberOpusController _controller; late final MemberOpusController _controller;
@override @override
@@ -50,71 +55,91 @@ class _MemberOpusState extends State<MemberOpus>
super.build(context); super.build(context);
final bottom = MediaQuery.viewPaddingOf(context).bottom; final bottom = MediaQuery.viewPaddingOf(context).bottom;
return Stack( return Stack(
clipBehavior: .none,
children: [ children: [
refreshIndicator( refreshIndicator(
onRefresh: _controller.onRefresh, onRefresh: _controller.onRefresh,
child: CustomScrollView( child: NotificationListener<UserScrollNotification>(
physics: const AlwaysScrollableScrollPhysics(), onNotification: (notification) {
slivers: [ final direction = notification.direction;
SliverPadding( if (direction == .forward) {
padding: EdgeInsets.only( showFab();
top: widget.isSingle ? 12 : 0, } else if (direction == .reverse) {
left: Style.safeSpace, hideFab();
right: Style.safeSpace, }
bottom: bottom + 100, return false;
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: EdgeInsets.only(
top: widget.isSingle ? 12 : 0,
left: Style.safeSpace,
right: Style.safeSpace,
bottom: bottom + 100,
),
sliver: Obx(() => _buildBody(_controller.loadingState.value)),
), ),
sliver: Obx(() => _buildBody(_controller.loadingState.value)), ],
), ),
],
), ),
), ),
if (_controller.filter?.isNotEmpty == true) if (_controller.filter?.isNotEmpty == true)
Positioned( Positioned(
right: kFloatingActionButtonMargin, right: kFloatingActionButtonMargin,
bottom: bottom + kFloatingActionButtonMargin, bottom: 0,
child: FloatingActionButton.extended( child: SlideTransition(
onPressed: () => showDialog( position: fabAnimation,
context: context, child: Padding(
builder: (context) => AlertDialog( padding: .only(
clipBehavior: Clip.hardEdge, bottom: bottom + kFloatingActionButtonMargin,
contentPadding: const EdgeInsets.symmetric(vertical: 12), ),
content: Column( child: FloatingActionButton.extended(
mainAxisSize: MainAxisSize.min, onPressed: () => showDialog(
children: _controller.filter! context: context,
.map( builder: (context) => AlertDialog(
(e) => ListTile( clipBehavior: Clip.hardEdge,
onTap: () { contentPadding: const EdgeInsets.symmetric(vertical: 12),
if (e == _controller.type.value) { content: Column(
return; mainAxisSize: MainAxisSize.min,
} children: _controller.filter!
Get.back(); .map(
_controller (e) => ListTile(
..type.value = e onTap: () {
..onReload(); if (e == _controller.type.value) {
}, return;
tileColor: e == _controller.type.value }
? Theme.of( Get.back();
context, _controller
).colorScheme.onInverseSurface ..type.value = e
: null, ..onReload();
dense: true, },
title: Text( tileColor: e == _controller.type.value
e.text ?? e.tabName!, ? Theme.of(
style: const TextStyle(fontSize: 14), context,
), ).colorScheme.onInverseSurface
), : null,
) dense: true,
.toList(), title: Text(
e.text ?? e.tabName!,
style: const TextStyle(fontSize: 14),
),
),
)
.toList(),
),
),
),
icon: const Icon(size: 20, Icons.sort),
label: Obx(
() {
final type = _controller.type.value;
return Text(type.text ?? type.tabName!);
},
), ),
), ),
), ),
icon: const Icon(size: 20, Icons.sort),
label: Obx(
() {
final type = _controller.type.value;
return Text(type.text ?? type.tabName!);
},
),
), ),
), ),
], ],

View File

@@ -6,6 +6,7 @@ import 'package:PiliPlus/common/widgets/sliver/sliver_floating_header.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/member/contribute_type.dart'; import 'package:PiliPlus/models/common/member/contribute_type.dart';
import 'package:PiliPlus/models_new/space/space_archive/item.dart'; import 'package:PiliPlus/models_new/space/space_archive/item.dart';
import 'package:PiliPlus/pages/common/fab_mixin.dart';
import 'package:PiliPlus/pages/member/controller.dart'; import 'package:PiliPlus/pages/member/controller.dart';
import 'package:PiliPlus/pages/member_video/controller.dart'; import 'package:PiliPlus/pages/member_video/controller.dart';
import 'package:PiliPlus/pages/member_video/widgets/video_card_h_member_video.dart'; import 'package:PiliPlus/pages/member_video/widgets/video_card_h_member_video.dart';
@@ -41,7 +42,12 @@ class MemberVideo extends StatefulWidget {
} }
class _MemberVideoState extends State<MemberVideo> class _MemberVideoState extends State<MemberVideo>
with AutomaticKeepAliveClientMixin, GridMixin { with
AutomaticKeepAliveClientMixin,
GridMixin,
SingleTickerProviderStateMixin,
BaseFabMixin,
LazyFabMixin {
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -121,32 +127,53 @@ class _MemberVideoState extends State<MemberVideo>
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
child, NotificationListener<UserScrollNotification>(
onNotification: (notification) {
final direction = notification.direction;
if (direction == .forward) {
showFab();
} else if (direction == .reverse) {
hideFab();
}
return false;
},
child: child,
),
Obx( Obx(
() => !_controller.isLocating.value () => !_controller.isLocating.value
? Positioned( ? Positioned(
right: kFloatingActionButtonMargin, right: kFloatingActionButtonMargin,
bottom: kFloatingActionButtonMargin + padding.bottom, bottom: 0,
child: FloatingActionButton.extended( child: SlideTransition(
onPressed: () { position: fabAnimation,
final fromViewAid = _controller.fromViewAid; child: Padding(
_controller.isLocating.value = true; padding: .only(
final locatedIndex = bottom: padding.bottom + kFloatingActionButtonMargin,
_controller.loadingState.value.dataOrNull ),
?.indexWhere((i) => i.param == fromViewAid) ?? child: FloatingActionButton.extended(
-1; onPressed: () {
if (locatedIndex == -1) { final fromViewAid = _controller.fromViewAid;
_controller _controller.isLocating.value = true;
..lastAid = fromViewAid final locatedIndex =
..reload = true _controller.loadingState.value.dataOrNull
..page = 0 ?.indexWhere(
..loadingState.value = LoadingState.loading() (i) => i.param == fromViewAid,
..queryData(); ) ??
} else { -1;
_jumpToIndex(locatedIndex); if (locatedIndex == -1) {
} _controller
}, ..lastAid = fromViewAid
label: const Text('定位至上次观看'), ..reload = true
..page = 0
..loadingState.value = LoadingState.loading()
..queryData();
} else {
_jumpToIndex(locatedIndex);
}
},
label: const Text('定位至上次观看'),
),
),
), ),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),

View File

@@ -121,6 +121,7 @@ abstract class BaseVideoWebState<
child: Padding( child: Padding(
padding: const .fromLTRB(14, 0, 8, 4), padding: const .fromLTRB(14, 0, 8, 4),
child: Stack( child: Stack(
clipBehavior: .none,
alignment: .centerLeft, alignment: .centerLeft,
children: [ children: [
?buildCount(), ?buildCount(),

View File

@@ -35,6 +35,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
with with
AutomaticKeepAliveClientMixin, AutomaticKeepAliveClientMixin,
SingleTickerProviderStateMixin, SingleTickerProviderStateMixin,
BaseFabMixin,
FabMixin { FabMixin {
late VideoReplyController _videoReplyController; late VideoReplyController _videoReplyController;