feat: 动态与专栏详情适配横屏双列模式

This commit is contained in:
orz12
2024-07-11 17:38:36 +08:00
parent 99224640d8
commit 1a9ee2d153
3 changed files with 386 additions and 320 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:ffi';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -10,12 +12,14 @@ class HtmlRender extends StatelessWidget {
this.htmlContent, this.htmlContent,
this.imgCount, this.imgCount,
this.imgList, this.imgList,
required this.constrainedWidth,
super.key, super.key,
}); });
final String? htmlContent; final String? htmlContent;
final int? imgCount; final int? imgCount;
final List<String>? imgList; final List<String>? imgList;
final double constrainedWidth;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -57,7 +61,7 @@ class HtmlRender extends StatelessWidget {
// height: isEmote ? 22 : null, // height: isEmote ? 22 : null,
// ); // );
return NetworkImgLayer( return NetworkImgLayer(
width: isEmote ? 22 : (Get.size.width - 23) / textScale, width: isEmote ? 22 : (constrainedWidth - 23) / textScale,
height: isEmote ? 22 : 200, height: isEmote ? 22 : 200,
src: imgUrl, src: imgUrl,
ignoreHeight: !isEmote, ignoreHeight: !isEmote,

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:easy_debounce/easy_throttle.dart'; import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -16,6 +17,7 @@ import 'package:PiliPalaX/pages/video/detail/reply_reply/index.dart';
import 'package:PiliPalaX/utils/feed_back.dart'; import 'package:PiliPalaX/utils/feed_back.dart';
import 'package:PiliPalaX/utils/id_utils.dart'; import 'package:PiliPalaX/utils/id_utils.dart';
import '../../../utils/grid.dart';
import '../widgets/dynamic_panel.dart'; import '../widgets/dynamic_panel.dart';
class DynamicDetailPage extends StatefulWidget { class DynamicDetailPage extends StatefulWidget {
@@ -212,158 +214,67 @@ class _DynamicDetailPageState extends State<DynamicDetailPage>
}, },
child: Stack( child: Stack(
children: [ children: [
CustomScrollView( OrientationBuilder(
controller: scrollController, builder: (context, orientation) {
physics: const AlwaysScrollableScrollPhysics(), double padding = max(context.width / 2 - Grid.maxRowWidth, 0);
slivers: [ if (orientation == Orientation.portrait) {
if (action != 'comment') return CustomScrollView(
SliverToBoxAdapter( controller: scrollController,
child: DynamicPanel( physics: const AlwaysScrollableScrollPhysics(),
item: _dynamicDetailController.item, slivers: [
source: 'detail', SliverToBoxAdapter(
), child: DynamicPanel(
), item: _dynamicDetailController.item,
SliverPersistentHeader( source: 'detail',
delegate: _MySliverPersistentHeaderDelegate(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
width: 0.6,
color: Theme.of(context)
.dividerColor
.withOpacity(0.05),
),
), ),
), ),
height: 45, replyPersistentHeader(context),
padding: const EdgeInsets.only(left: 12, right: 6), replyList(),
child: Row( ]
children: [ .map<Widget>((e) => SliverPadding(
Obx( padding: EdgeInsets.symmetric(horizontal: padding),
() => AnimatedSwitcher( sliver: e))
duration: const Duration(milliseconds: 400), .toList(),
transitionBuilder: );
(Widget child, Animation<double> animation) { } else {
return ScaleTransition( return Row(
scale: animation, child: child); children: [
}, Expanded(
child: Text( child: CustomScrollView(
'${_dynamicDetailController.acount.value}条回复', controller: ScrollController(),
key: ValueKey<int>( physics: const AlwaysScrollableScrollPhysics(),
_dynamicDetailController.acount.value), slivers: [
), SliverPadding(
), padding: EdgeInsets.only(left: padding / 2),
), sliver: SliverToBoxAdapter(
const Spacer(), child: DynamicPanel(
SizedBox( item: _dynamicDetailController.item,
height: 35, source: 'detail',
child: TextButton.icon( ),
onPressed: () =>
_dynamicDetailController.queryBySort(),
icon: const Icon(Icons.sort, size: 16),
label: Obx(() => Text(
_dynamicDetailController
.sortTypeLabel.value,
style: const TextStyle(fontSize: 13),
)), )),
), ]),
)
],
), ),
), Expanded(
), child: CustomScrollView(
pinned: true, controller: scrollController,
), physics: const AlwaysScrollableScrollPhysics(),
FutureBuilder( slivers: [
future: _futureBuilderFuture, SliverPadding(
builder: (context, snapshot) { padding: EdgeInsets.only(right: padding / 2),
if (snapshot.connectionState == ConnectionState.done) { sliver: replyPersistentHeader(context)),
Map data = snapshot.data as Map; SliverPadding(
if (snapshot.data['status']) { padding: EdgeInsets.only(right: padding / 2),
// 请求成功 sliver: replyList()),
return Obx( ]
() => _dynamicDetailController.replyList.isEmpty && // .map<Widget>(
_dynamicDetailController.isLoadingMore // (e) => SliverPadding(padding: padding, sliver: e))
? SliverList( // .toList(),
delegate: SliverChildBuilderDelegate( ),
(context, index) { ),
return const VideoReplySkeleton(); ],
}, childCount: 8), );
) }
: SliverList( },
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index ==
_dynamicDetailController
.replyList.length) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context)
.padding
.bottom),
height: MediaQuery.of(context)
.padding
.bottom +
100,
child: Center(
child: Obx(
() => Text(
_dynamicDetailController
.noMore.value,
style: TextStyle(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.outline,
),
),
),
),
);
} else {
return ReplyItem(
replyItem: _dynamicDetailController
.replyList[index],
showReplyRow: true,
replyLevel: '1',
replyReply: (replyItem) =>
replyReply(replyItem),
replyType:
ReplyType.values[replyType],
addReply: (replyItem) {
_dynamicDetailController
.replyList[index].replies!
.add(replyItem);
},
);
}
},
childCount: _dynamicDetailController
.replyList.length +
1,
),
),
);
} else {
// 请求错误
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoReplySkeleton();
}, childCount: 8),
);
}
},
)
],
), ),
Positioned( Positioned(
bottom: MediaQuery.of(context).padding.bottom + 14, bottom: MediaQuery.of(context).padding.bottom + 14,
@@ -415,6 +326,136 @@ class _DynamicDetailPageState extends State<DynamicDetailPage>
), ),
); );
} }
SliverPersistentHeader replyPersistentHeader(BuildContext context) {
return SliverPersistentHeader(
delegate: _MySliverPersistentHeaderDelegate(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
width: 0.6,
color: Theme.of(context).dividerColor.withOpacity(0.05),
),
),
),
height: 45,
padding: const EdgeInsets.only(left: 12, right: 6),
child: Row(
children: [
Obx(
() => AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
'${_dynamicDetailController.acount.value}条回复',
key: ValueKey<int>(_dynamicDetailController.acount.value),
),
),
),
const Spacer(),
SizedBox(
height: 35,
child: TextButton.icon(
onPressed: () => _dynamicDetailController.queryBySort(),
icon: const Icon(Icons.sort, size: 16),
label: Obx(() => Text(
_dynamicDetailController.sortTypeLabel.value,
style: const TextStyle(fontSize: 13),
)),
),
)
],
),
),
),
pinned: true,
);
}
FutureBuilder<dynamic> replyList() {
return FutureBuilder(
future: _futureBuilderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
Map data = snapshot.data as Map;
if (snapshot.data['status']) {
// 请求成功
return Obx(
() => _dynamicDetailController.replyList.isEmpty &&
_dynamicDetailController.isLoadingMore
? SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoReplySkeleton();
}, childCount: 8),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index ==
_dynamicDetailController.replyList.length) {
return Container(
padding: EdgeInsets.only(
bottom:
MediaQuery.of(context).padding.bottom),
height:
MediaQuery.of(context).padding.bottom + 100,
child: Center(
child: Obx(
() => Text(
_dynamicDetailController.noMore.value,
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.outline,
),
),
),
),
);
} else {
return ReplyItem(
replyItem:
_dynamicDetailController.replyList[index],
showReplyRow: true,
replyLevel: '1',
replyReply: (replyItem) => replyReply(replyItem),
replyType: ReplyType.values[replyType],
addReply: (replyItem) {
_dynamicDetailController
.replyList[index].replies!
.add(replyItem);
},
);
}
},
childCount:
_dynamicDetailController.replyList.length + 1,
),
),
);
} else {
// 请求错误
return HttpError(
errMsg: data['msg'],
fn: () => setState(() {}),
);
}
} else {
// 骨架屏
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return const VideoReplySkeleton();
}, childCount: 8),
);
}
},
);
}
} }
class _MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { class _MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {

View File

@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:easy_debounce/easy_throttle.dart'; import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@@ -13,6 +15,7 @@ import 'package:PiliPalaX/pages/video/detail/reply_new/index.dart';
import 'package:PiliPalaX/pages/video/detail/reply_reply/index.dart'; import 'package:PiliPalaX/pages/video/detail/reply_reply/index.dart';
import 'package:PiliPalaX/utils/feed_back.dart'; import 'package:PiliPalaX/utils/feed_back.dart';
import '../../utils/grid.dart';
import 'controller.dart'; import 'controller.dart';
class HtmlRenderPage extends StatefulWidget { class HtmlRenderPage extends StatefulWidget {
@@ -210,181 +213,123 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
), ),
body: Stack( body: Stack(
children: [ children: [
SingleChildScrollView( OrientationBuilder(builder: (context, orientation) {
controller: scrollController, double padding = max(context.width / 2 - Grid.maxRowWidth, 0);
child: FutureBuilder( return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
future: _futureBuilderFuture, Expanded(
builder: (context, snapshot) { child: SingleChildScrollView(
if (snapshot.connectionState == ConnectionState.done && controller: orientation == Orientation.portrait
snapshot.hasData) { ? scrollController
var data = snapshot.data; : ScrollController(),
// fabAnimationCtr.forward(); child: Padding(
if (data != null && data['status']) { padding: orientation == Orientation.portrait
return Column( ? EdgeInsets.symmetric(horizontal: padding)
children: [ : EdgeInsets.only(left: padding / 2),
Padding( child: FutureBuilder(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), future: _futureBuilderFuture,
child: Row( builder: (context, snapshot) {
children: [ if (snapshot.connectionState == ConnectionState.done &&
NetworkImgLayer( snapshot.hasData) {
width: 40, var data = snapshot.data;
height: 40, // fabAnimationCtr.forward();
type: 'avatar', if (data != null && data['status']) {
src: _htmlRenderCtr.response['avatar']!, return Column(
), children: [
const SizedBox(width: 10), Padding(
Column( padding:
crossAxisAlignment: CrossAxisAlignment.start, const EdgeInsets.fromLTRB(12, 12, 12, 8),
children: [ child: Row(
Text(_htmlRenderCtr.response['uname'], children: [
style: TextStyle( NetworkImgLayer(
fontSize: Theme.of(context) width: 40,
.textTheme height: 40,
.titleSmall! type: 'avatar',
.fontSize, src: _htmlRenderCtr.response['avatar']!,
)), ),
Text( const SizedBox(width: 10),
_htmlRenderCtr.response['updateTime'], Column(
style: TextStyle( crossAxisAlignment:
color: CrossAxisAlignment.start,
Theme.of(context).colorScheme.outline, children: [
fontSize: Theme.of(context) Text(_htmlRenderCtr.response['uname'],
.textTheme
.labelSmall!
.fontSize,
),
),
],
),
const Spacer(),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: HtmlRender(
htmlContent: _htmlRenderCtr.response['content'],
),
),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 8,
color: Theme.of(context)
.dividerColor
.withOpacity(0.05),
),
),
),
),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
width: 0.6,
color: Theme.of(context)
.dividerColor
.withOpacity(0.05),
),
),
),
height: 45,
padding: const EdgeInsets.only(left: 12, right: 6),
child: Row(
children: [
const Text('回复'),
const Spacer(),
SizedBox(
height: 35,
child: TextButton.icon(
onPressed: () => _htmlRenderCtr.queryBySort(),
icon: const Icon(Icons.sort, size: 16),
label: Obx(
() => Text(
_htmlRenderCtr.sortTypeLabel.value,
style: const TextStyle(fontSize: 13),
),
),
),
)
],
),
),
Obx(
() => _htmlRenderCtr.replyList.isEmpty &&
_htmlRenderCtr.isLoadingMore
? ListView.builder(
itemCount: 5,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return const VideoReplySkeleton();
},
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount:
_htmlRenderCtr.replyList.length + 1,
itemBuilder: (context, index) {
if (index ==
_htmlRenderCtr.replyList.length) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context)
.padding
.bottom),
height: MediaQuery.of(context)
.padding
.bottom +
100,
child: Center(
child: Obx(
() => Text(
_htmlRenderCtr.noMore.value,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: Theme.of(context)
color: Theme.of(context) .textTheme
.colorScheme .titleSmall!
.outline, .fontSize,
), )),
Text(
_htmlRenderCtr
.response['updateTime'],
style: TextStyle(
color: Theme.of(context)
.colorScheme
.outline,
fontSize: Theme.of(context)
.textTheme
.labelSmall!
.fontSize,
), ),
), ),
), ],
); ),
} else { const Spacer(),
return ReplyItem( ],
replyItem: ),
_htmlRenderCtr.replyList[index],
showReplyRow: true,
replyLevel: '1',
replyReply: (replyItem) =>
replyReply(replyItem),
replyType: ReplyType.values[type],
addReply: (replyItem) {
_htmlRenderCtr
.replyList[index].replies!
.add(replyItem);
},
);
}
},
), ),
), Padding(
], padding:
); const EdgeInsets.fromLTRB(12, 8, 12, 8),
} else { child: LayoutBuilder(
return const Text('error'); builder: (context, boxConstraints) {
} return HtmlRender(
} else { htmlContent:
// 骨架屏 _htmlRenderCtr.response['content'],
return const SizedBox(); constrainedWidth:
} boxConstraints.maxWidth,
}, );
), },
), ),
),
if (orientation == Orientation.portrait) ...[
Divider(
thickness: 8,
color: Theme.of(context)
.dividerColor
.withOpacity(0.05)),
replyHeader(),
replyList(),
]
],
);
} else {
return const Text('error');
}
} else {
// 骨架屏
return const SizedBox();
}
},
)),
)),
if (orientation == Orientation.landscape) ...[
VerticalDivider(
thickness: 8,
color: Theme.of(context).dividerColor.withOpacity(0.05)),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.only(right: padding / 2),
child: Column(
children: [
replyHeader(),
replyList(),
],
))))
]
]);
}),
Positioned( Positioned(
bottom: MediaQuery.of(context).padding.bottom + 14, bottom: MediaQuery.of(context).padding.bottom + 14,
right: 14, right: 14,
@@ -432,4 +377,80 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
), ),
); );
} }
Obx replyList() {
return Obx(
() => _htmlRenderCtr.replyList.isEmpty && _htmlRenderCtr.isLoadingMore
? ListView.builder(
itemCount: 5,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return const VideoReplySkeleton();
},
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _htmlRenderCtr.replyList.length + 1,
itemBuilder: (context, index) {
if (index == _htmlRenderCtr.replyList.length) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom),
height: MediaQuery.of(context).padding.bottom + 100,
child: Center(
child: Obx(
() => Text(
_htmlRenderCtr.noMore.value,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline,
),
),
),
),
);
} else {
return ReplyItem(
replyItem: _htmlRenderCtr.replyList[index],
showReplyRow: true,
replyLevel: '1',
replyReply: (replyItem) => replyReply(replyItem),
replyType: ReplyType.values[type],
addReply: (replyItem) {
_htmlRenderCtr.replyList[index].replies!.add(replyItem);
},
);
}
},
),
);
}
Container replyHeader() {
return Container(
height: 45,
padding: const EdgeInsets.only(left: 12, right: 6),
child: Row(
children: [
const Text('回复'),
const Spacer(),
SizedBox(
height: 35,
child: TextButton.icon(
onPressed: () => _htmlRenderCtr.queryBySort(),
icon: const Icon(Icons.sort, size: 16),
label: Obx(
() => Text(
_htmlRenderCtr.sortTypeLabel.value,
style: const TextStyle(fontSize: 13),
),
),
),
)
],
),
);
}
} }