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(
builder: (context, orientation) {
double padding = max(context.width / 2 - Grid.maxRowWidth, 0);
if (orientation == Orientation.portrait) {
return CustomScrollView(
controller: scrollController, controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
if (action != 'comment')
SliverToBoxAdapter( SliverToBoxAdapter(
child: DynamicPanel( child: DynamicPanel(
item: _dynamicDetailController.item, item: _dynamicDetailController.item,
source: 'detail', source: 'detail',
), ),
), ),
SliverPersistentHeader( replyPersistentHeader(context),
delegate: _MySliverPersistentHeaderDelegate( replyList(),
child: Container( ]
decoration: BoxDecoration( .map<Widget>((e) => SliverPadding(
color: Theme.of(context).colorScheme.surface, padding: EdgeInsets.symmetric(horizontal: padding),
border: Border( sliver: e))
top: BorderSide( .toList(),
width: 0.6, );
color: Theme.of(context) } else {
.dividerColor return Row(
.withOpacity(0.05),
),
),
),
height: 45,
padding: const EdgeInsets.only(left: 12, right: 6),
child: Row(
children: [ children: [
Obx( Expanded(
() => AnimatedSwitcher( child: CustomScrollView(
duration: const Duration(milliseconds: 400), controller: ScrollController(),
transitionBuilder: physics: const AlwaysScrollableScrollPhysics(),
(Widget child, Animation<double> animation) { slivers: [
return ScaleTransition( SliverPadding(
scale: animation, child: child); padding: EdgeInsets.only(left: padding / 2),
}, sliver: SliverToBoxAdapter(
child: Text( child: DynamicPanel(
'${_dynamicDetailController.acount.value}条回复', item: _dynamicDetailController.item,
key: ValueKey<int>( source: 'detail',
_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),
)), )),
]),
),
Expanded(
child: CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: EdgeInsets.only(right: padding / 2),
sliver: replyPersistentHeader(context)),
SliverPadding(
padding: EdgeInsets.only(right: padding / 2),
sliver: replyList()),
]
// .map<Widget>(
// (e) => SliverPadding(padding: padding, sliver: e))
// .toList(),
),
), ),
)
], ],
),
),
),
pinned: true,
),
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),
);
}
},
)
],
), ),
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,8 +213,18 @@ 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);
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: SingleChildScrollView(
controller: orientation == Orientation.portrait
? scrollController
: ScrollController(),
child: Padding(
padding: orientation == Orientation.portrait
? EdgeInsets.symmetric(horizontal: padding)
: EdgeInsets.only(left: padding / 2),
child: FutureBuilder( child: FutureBuilder(
future: _futureBuilderFuture, future: _futureBuilderFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -223,7 +236,8 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), padding:
const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Row( child: Row(
children: [ children: [
NetworkImgLayer( NetworkImgLayer(
@@ -234,7 +248,8 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text(_htmlRenderCtr.response['uname'], Text(_htmlRenderCtr.response['uname'],
style: TextStyle( style: TextStyle(
@@ -244,10 +259,12 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
.fontSize, .fontSize,
)), )),
Text( Text(
_htmlRenderCtr.response['updateTime'], _htmlRenderCtr
.response['updateTime'],
style: TextStyle( style: TextStyle(
color: color: Theme.of(context)
Theme.of(context).colorScheme.outline, .colorScheme
.outline,
fontSize: Theme.of(context) fontSize: Theme.of(context)
.textTheme .textTheme
.labelSmall! .labelSmall!
@@ -261,118 +278,28 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), padding:
child: HtmlRender( const EdgeInsets.fromLTRB(12, 8, 12, 8),
htmlContent: _htmlRenderCtr.response['content'], child: LayoutBuilder(
builder: (context, boxConstraints) {
return HtmlRender(
htmlContent:
_htmlRenderCtr.response['content'],
constrainedWidth:
boxConstraints.maxWidth,
);
},
), ),
), ),
Container( if (orientation == Orientation.portrait) ...[
decoration: BoxDecoration( Divider(
border: Border( thickness: 8,
bottom: BorderSide(
width: 8,
color: Theme.of(context) color: Theme.of(context)
.dividerColor .dividerColor
.withOpacity(0.05), .withOpacity(0.05)),
), replyHeader(),
), replyList(),
), ]
),
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(
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);
},
);
}
},
),
),
], ],
); );
} else { } else {
@@ -383,8 +310,26 @@ class _HtmlRenderPageState extends State<HtmlRenderPage>
return const SizedBox(); 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),
),
),
),
)
],
),
);
}
} }