feat: sliver wrap (#1858)

* feat: sliver wrap

* opt: list

* update

---------

Co-authored-by: dom <githubaccount56556@proton.me>
This commit is contained in:
My-Responsitories
2026-03-08 15:25:44 +08:00
committed by GitHub
parent 01a74e191a
commit c01318c066
5 changed files with 789 additions and 294 deletions

View File

@@ -3,17 +3,19 @@ import 'package:flutter_svg/flutter_svg.dart';
class HttpError extends StatelessWidget {
const HttpError({
super.key,
this.isSliver = true,
this.errMsg,
this.onReload,
this.btnText,
super.key,
this.safeArea = true,
});
final bool isSliver;
final String? errMsg;
final VoidCallback? onReload;
final String? btnText;
final bool safeArea;
@override
Widget build(BuildContext context) {
@@ -57,7 +59,8 @@ class HttpError extends StatelessWidget {
style: TextStyle(color: theme.colorScheme.primary),
),
),
SizedBox(height: 40 + MediaQuery.viewPaddingOf(context).bottom),
if (safeArea)
SizedBox(height: 40 + MediaQuery.viewPaddingOf(context).bottom),
],
);
}

View File

@@ -0,0 +1,352 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class SliverFixedWrap extends SliverMultiBoxAdaptorWidget {
final double mainAxisExtent;
final double spacing;
final double runSpacing;
const SliverFixedWrap({
super.key,
required super.delegate,
required this.mainAxisExtent,
this.spacing = 0,
this.runSpacing = 0,
});
@override
SliverWrapElement createElement() =>
SliverWrapElement(this, replaceMovedChildren: true);
@override
RenderSliverFixedWrap createRenderObject(BuildContext context) {
return RenderSliverFixedWrap(
childManager: context as SliverWrapElement,
mainAxisExtent: mainAxisExtent,
spacing: spacing,
runSpacing: runSpacing,
);
}
@override
void updateRenderObject(
BuildContext context,
RenderSliverFixedWrap renderObject,
) {
renderObject
..mainAxisExtent = mainAxisExtent
..spacing = spacing
..runSpacing = runSpacing;
}
}
class SliverWrapParentData extends SliverMultiBoxAdaptorParentData {
double crossAxisOffset = 0.0;
@override
String toString() => 'crossAxisOffset=$crossAxisOffset; ${super.toString()}';
}
class _Row {
final int startIndex;
final int endIndex;
final List<double> childWidths;
_Row({
required this.startIndex,
required this.endIndex,
required this.childWidths,
});
}
class RenderSliverFixedWrap extends RenderSliverMultiBoxAdaptor {
RenderSliverFixedWrap({
required super.childManager,
required double mainAxisExtent,
double spacing = 0.0,
double runSpacing = 0.0,
}) : _mainAxisExtent = mainAxisExtent,
_spacing = spacing,
_runSpacing = runSpacing {
assert(mainAxisExtent > 0.0 && mainAxisExtent.isFinite);
}
double _mainAxisExtent;
double get mainAxisExtent => _mainAxisExtent;
set mainAxisExtent(double value) {
if (_mainAxisExtent == value) return;
_mainAxisExtent = value;
markRowsDirty();
markNeedsLayout();
}
double _spacing;
double get spacing => _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markRowsDirty();
markNeedsLayout();
}
double _runSpacing;
double get runSpacing => _runSpacing;
set runSpacing(double value) {
if (_runSpacing == value) return;
_runSpacing = value;
markRowsDirty();
markNeedsLayout();
}
final List<_Row> _rows = [];
void markRowsDirty() {
_rows.clear();
}
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverWrapParentData) {
child.parentData = SliverWrapParentData();
}
}
@override
double childCrossAxisPosition(RenderBox child) {
return (child.parentData as SliverWrapParentData).crossAxisOffset;
}
double _childCrossExtent(RenderBox child) {
assert(child.hasSize);
return switch (constraints.axis) {
Axis.horizontal => child.size.height,
Axis.vertical => child.size.width,
};
}
RenderBox _getOrCreateChildAtIndex(
int index,
BoxConstraints constraints,
RenderBox? child,
) {
assert(firstChild != null);
if (index < indexOf(firstChild!)) {
do {
child = insertAndLayoutLeadingChild(constraints, parentUsesSize: true);
assert(child != null);
} while (indexOf(child!) > index);
assert(indexOf(child) == index);
return child;
} else if (index > indexOf(lastChild!)) {
do {
child = insertAndLayoutChild(
constraints,
after: lastChild,
parentUsesSize: true,
);
assert(child != null);
} while (indexOf(child!) < index);
assert(indexOf(child) == index);
return child;
} else {
child = firstChild;
while (indexOf(child!) < index) {
child = childAfter(child);
}
if (indexOf(child) == index) {
child.layout(constraints, parentUsesSize: true);
return child;
}
throw RangeError.value(index, 'index', 'Value not included in children');
}
}
bool _buildNextRow(int start, BoxConstraints childConstraints) {
final int childCount = childManager.childCount;
if (start >= childCount) {
return false;
}
final crossAxisExtent = constraints.crossAxisExtent;
final List<double> widths = [];
int idx = start;
RenderBox? child;
for (var totalWidth = -_spacing; idx < childCount; idx++) {
child = _getOrCreateChildAtIndex(idx, childConstraints, child);
final childWidth = _childCrossExtent(child);
totalWidth += childWidth + _spacing;
if (totalWidth <= crossAxisExtent) {
widths.add(childWidth);
} else {
break;
}
}
_rows.add(_Row(startIndex: start, endIndex: idx - 1, childWidths: widths));
return true;
}
@override
void performLayout() {
childManager
..didStartLayout()
..setDidUnderflow(false);
final constraints = this.constraints;
final childCount = childManager.childCount;
final rowHeight = _mainAxisExtent + _runSpacing;
final scrollOffset = constraints.scrollOffset;
final firstCacheOffset = scrollOffset + constraints.cacheOrigin;
final lastCacheOffset = scrollOffset + constraints.remainingCacheExtent;
final firstNeededRow = math.max(0, firstCacheOffset ~/ rowHeight);
final lastNeededRow = math.max(0, lastCacheOffset ~/ rowHeight);
if (firstChild == null) {
if (!addInitialChild()) {
geometry = SliverGeometry.zero;
childManager.didFinishLayout();
return;
}
firstChild!.layout(
constraints.toFixedConstraints(_mainAxisExtent),
parentUsesSize: true,
);
}
while (_rows.length <= lastNeededRow) {
final int startIndex = _rows.isEmpty ? 0 : _rows.last.endIndex + 1;
if (!_buildNextRow(
startIndex,
constraints.toFixedConstraints(_mainAxisExtent),
)) {
break;
}
}
assert(firstNeededRow >= 0);
final int firstKeptRow = firstNeededRow.clamp(0, _rows.length - 1);
final int lastKeptRow = lastNeededRow.clamp(0, _rows.length - 1);
final int firstKeptIndex = _rows[firstKeptRow].startIndex;
final int lastKeptIndex = _rows[lastKeptRow].endIndex;
collectGarbage(
calculateLeadingGarbage(firstIndex: firstKeptIndex),
calculateTrailingGarbage(lastIndex: lastKeptIndex),
);
RenderBox? child;
for (var r = firstKeptRow; r <= lastKeptRow; r++) {
final row = _rows[r];
final rowStartOffset = r * rowHeight;
double crossOffset = 0.0;
for (var i = row.startIndex; i <= row.endIndex; i++) {
child = _getOrCreateChildAtIndex(
i,
constraints.toFixedConstraints(_mainAxisExtent),
child,
);
(child.parentData as SliverWrapParentData)
..layoutOffset = rowStartOffset
..crossAxisOffset = crossOffset;
crossOffset += row.childWidths[i - row.startIndex] + _spacing;
}
}
final endOffset = _rows.last.endIndex == childCount - 1
? (_rows.length * rowHeight)
: (_rows.last.startIndex + 1) * rowHeight;
final double estimatedMaxScrollOffset;
if (_rows.length <= lastNeededRow || childCount == 0) {
estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstKeptIndex,
lastIndex: lastKeptIndex,
leadingScrollOffset: firstKeptRow * rowHeight,
trailingScrollOffset: endOffset,
);
} else {
estimatedMaxScrollOffset = _rows.length * rowHeight;
}
final double paintExtent = calculatePaintOffset(
constraints,
from: firstKeptRow * rowHeight,
to: endOffset,
);
final double cacheExtent = calculateCacheOffset(
constraints,
from: firstCacheOffset,
to: lastCacheOffset,
);
geometry = SliverGeometry(
scrollExtent: estimatedMaxScrollOffset,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: estimatedMaxScrollOffset,
hasVisualOverflow:
endOffset >
constraints.scrollOffset + constraints.remainingPaintExtent,
);
if (estimatedMaxScrollOffset <= endOffset) {
childManager.setDidUnderflow(true);
}
childManager.didFinishLayout();
}
@override
void dispose() {
markRowsDirty();
super.dispose();
}
}
class SliverWrapElement extends SliverMultiBoxAdaptorElement {
SliverWrapElement(SliverFixedWrap super.widget, {super.replaceMovedChildren});
@override
void performRebuild() {
(renderObject as RenderSliverFixedWrap).markRowsDirty();
super.performRebuild();
}
}
extension on SliverConstraints {
BoxConstraints toFixedConstraints(double mainAxisExtent) {
switch (axis) {
case Axis.horizontal:
return BoxConstraints(
minHeight: 0,
maxHeight: crossAxisExtent,
minWidth: mainAxisExtent,
maxWidth: mainAxisExtent,
);
case Axis.vertical:
return BoxConstraints(
minWidth: 0,
maxWidth: crossAxisExtent,
minHeight: mainAxisExtent,
maxHeight: mainAxisExtent,
);
}
}
}

View File

@@ -1,8 +1,8 @@
import 'dart:convert';
import 'package:PiliPlus/common/widgets/disabled_icon.dart';
import 'package:PiliPlus/common/widgets/flutter/layout_builder.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/common/widgets/sliver_wrap.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models_new/search/search_rcmd/data.dart';
import 'package:PiliPlus/pages/about/view.dart' show showImportExportDialog;
@@ -27,6 +27,9 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
final _tag = Utils.generateRandomString(6);
late final SSearchController _searchController;
late ThemeData theme;
late bool isPortrait;
late EdgeInsets padding;
@override
void initState() {
@@ -37,109 +40,111 @@ class _SearchPageState extends State<SearchPage> {
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
theme = Theme.of(context);
padding = MediaQuery.viewPaddingOf(context);
isPortrait = MediaQuery.sizeOf(context).isPortrait;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isPortrait = MediaQuery.sizeOf(context).isPortrait;
final trending = _searchController.enableTrending
? _buildHotSearch()
: null;
final rcmd = _searchController.enableSearchRcmd
? _buildHotSearch(isTrending: false)
: null;
return Scaffold(
appBar: AppBar(
shape: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.08),
width: 1,
),
appBar: _buildAppBar,
body: Padding(
padding: .only(left: padding.left, right: padding.right),
child: CustomScrollView(
slivers: [
if (_searchController.searchSuggestion) _buildSearchSuggest(),
if (isPortrait) ...[
?trending,
_buildHistory,
?rcmd,
] else if (_searchController.enableTrending ||
_searchController.enableSearchRcmd)
SliverCrossAxisGroup(
slivers: [
SliverMainAxisGroup(slivers: [?trending, ?rcmd]),
_buildHistory,
],
)
else
_buildHistory,
SliverPadding(padding: .only(bottom: padding.bottom)),
],
),
actions: [
Obx(
() => _searchController.showUidBtn.value
? IconButton(
tooltip: 'UID搜索用户',
icon: const Icon(Icons.person_outline, size: 22),
onPressed: () => Get.toNamed(
'/member?mid=${_searchController.controller.text}',
),
)
: const SizedBox.shrink(),
),
IconButton(
tooltip: '清空',
icon: const Icon(Icons.clear, size: 22),
onPressed: _searchController.onClear,
),
IconButton(
tooltip: '搜索',
onPressed: _searchController.submit,
icon: const Icon(Icons.search, size: 22),
),
const SizedBox(width: 10),
],
title: TextField(
autofocus: true,
focusNode: _searchController.searchFocusNode,
controller: _searchController.controller,
textInputAction: TextInputAction.search,
onChanged: _searchController.onChange,
decoration: InputDecoration(
visualDensity: .standard,
hintText: _searchController.hintText ?? '搜索',
border: InputBorder.none,
),
onSubmitted: (value) => _searchController.submit(),
),
),
body: ListView(
padding: MediaQuery.viewPaddingOf(context).copyWith(top: 0),
children: [
if (_searchController.searchSuggestion) _searchSuggest(),
if (isPortrait) ...[
if (_searchController.enableTrending) hotSearch(theme, isPortrait),
_history(theme, isPortrait),
if (_searchController.enableSearchRcmd)
hotSearch(theme, isPortrait, isTrending: false),
] else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_searchController.enableTrending ||
_searchController.enableSearchRcmd)
Expanded(
child: Column(
children: [
if (_searchController.enableTrending)
hotSearch(theme, isPortrait),
if (_searchController.enableSearchRcmd)
hotSearch(theme, isPortrait, isTrending: false),
],
),
),
Expanded(child: _history(theme, isPortrait)),
],
),
],
),
);
}
Widget _searchSuggest() {
return Obx(
() =>
_searchController.searchSuggestList.isNotEmpty &&
_searchController.searchSuggestList.first.term != null &&
PreferredSizeWidget get _buildAppBar => AppBar(
shape: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.08),
width: 1,
),
),
actions: [
Obx(
() => _searchController.showUidBtn.value
? IconButton(
tooltip: 'UID搜索用户',
icon: const Icon(Icons.person_outline, size: 22),
onPressed: () => Get.toNamed(
'/member?mid=${_searchController.controller.text}',
),
)
: const SizedBox.shrink(),
),
IconButton(
tooltip: '清空',
icon: const Icon(Icons.clear, size: 22),
onPressed: _searchController.onClear,
),
IconButton(
tooltip: '搜索',
onPressed: _searchController.submit,
icon: const Icon(Icons.search, size: 22),
),
const SizedBox(width: 10),
],
title: TextField(
autofocus: true,
focusNode: _searchController.searchFocusNode,
controller: _searchController.controller,
textInputAction: TextInputAction.search,
onChanged: _searchController.onChange,
decoration: InputDecoration(
visualDensity: .standard,
hintText: _searchController.hintText ?? '搜索',
border: InputBorder.none,
),
onSubmitted: (value) => _searchController.submit(),
),
);
Widget _buildSearchSuggest() {
return Obx(() {
final list = _searchController.searchSuggestList;
return list.isNotEmpty &&
list.first.term != null &&
_searchController.controller.text != ''
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _searchController.searchSuggestList
? SliverList.list(
children: list
.map(
(item) => InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4)),
borderRadius: const .all(.circular(4)),
onTap: () => _searchController.onClickKeyword(item.term!),
child: Padding(
padding: const EdgeInsets.only(
left: 20,
top: 9,
bottom: 9,
),
padding: const .only(left: 20, top: 9, bottom: 9),
child: Text.rich(
TextSpan(
children: Em.regTitle(item.textRich)
@@ -164,11 +169,13 @@ class _SearchPageState extends State<SearchPage> {
)
.toList(),
)
: const SizedBox.shrink(),
);
: const SliverToBoxAdapter();
});
}
Widget hotSearch(ThemeData theme, bool isPortrait, {bool isTrending = true}) {
Widget _buildHotSearch({
bool isTrending = true,
}) {
final text = Text(
isTrending ? '大家都在搜' : '搜索发现',
strutStyle: const StrutStyle(leading: 0, height: 1),
@@ -184,7 +191,7 @@ class _SearchPageState extends State<SearchPage> {
fontSize: 13,
color: outline,
);
return Padding(
return SliverPadding(
padding: EdgeInsets.fromLTRB(
10,
!isTrending && (isPortrait || _searchController.enableTrending)
@@ -193,23 +200,28 @@ class _SearchPageState extends State<SearchPage> {
4,
25,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
sliver: SliverMainAxisGroup(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
isTrending
? Row(
mainAxisSize: MainAxisSize.min,
children: [
text,
const SizedBox(width: 14),
SizedBox(
height: 34,
child: TextButton(
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
isTrending
? Row(
mainAxisSize: MainAxisSize.min,
children: [
text,
const SizedBox(width: 14),
TextButton(
style: const ButtonStyle(
visualDensity: .compact,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(
.symmetric(horizontal: 10),
),
),
onPressed: () => Get.toNamed('/searchTrending'),
child: Row(
children: [
@@ -229,16 +241,15 @@ class _SearchPageState extends State<SearchPage> {
],
),
),
),
],
)
: text,
SizedBox(
height: 34,
child: TextButton.icon(
],
)
: text,
TextButton.icon(
style: const ButtonStyle(
visualDensity: .compact,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10, vertical: 6),
.symmetric(horizontal: 10),
),
),
onPressed: isTrending
@@ -258,8 +269,8 @@ class _SearchPageState extends State<SearchPage> {
),
),
),
),
],
],
),
),
),
Obx(
@@ -275,14 +286,16 @@ class _SearchPageState extends State<SearchPage> {
);
}
Widget _history(ThemeData theme, bool isPortrait) {
late final mainAxisExtent = 16 + MediaQuery.textScalerOf(context).scale(14);
Widget get _buildHistory {
return Obx(
() {
if (_searchController.historyList.isEmpty) {
return const SizedBox.shrink();
final list = _searchController.historyList;
if (list.isEmpty) {
return const SliverToBoxAdapter();
}
final secondary = theme.colorScheme.secondary;
return Padding(
return SliverPadding(
padding: EdgeInsets.fromLTRB(
10,
!isPortrait
@@ -293,67 +306,31 @@ class _SearchPageState extends State<SearchPage> {
6,
25,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
sliver: SliverMainAxisGroup(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(6, 0, 6, 6),
child: Row(
children: [
Text(
'搜索历史',
strutStyle: const StrutStyle(leading: 0, height: 1),
style: theme.textTheme.titleMedium!.copyWith(
height: 1,
fontWeight: FontWeight.bold,
sliver: SliverToBoxAdapter(
child: Row(
children: [
Text(
'搜索历史',
strutStyle: const StrutStyle(leading: 0, height: 1),
style: theme.textTheme.titleMedium!.copyWith(
height: 1,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Obx(
() {
bool enable =
_searchController.recordSearchHistory.value;
return SizedBox(
width: 34,
height: 34,
child: IconButton(
iconSize: 22,
tooltip: enable ? '记录搜索' : '无痕搜索',
icon: DisabledIcon(
disable: !enable,
child: Icon(
Icons.history,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.8),
),
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
),
onPressed: () {
enable = !enable;
_searchController.recordSearchHistory.value =
enable;
GStorage.setting.put(
SettingBoxKey.recordSearchHistory,
enable,
);
},
),
);
},
),
_exportHistory(theme),
const Spacer(),
SizedBox(
height: 34,
child: TextButton.icon(
const SizedBox(width: 12),
_recordBtn,
_exportBtn,
const Spacer(),
TextButton.icon(
style: const ButtonStyle(
visualDensity: .compact,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
.symmetric(horizontal: 10),
),
),
onPressed: _searchController.onClearHistory,
@@ -364,27 +341,36 @@ class _SearchPageState extends State<SearchPage> {
),
label: Text(
'清空',
style: TextStyle(color: secondary),
style: TextStyle(
height: 1,
color: secondary,
),
),
),
),
],
],
),
),
),
Wrap(
SliverFixedWrap(
mainAxisExtent: mainAxisExtent,
spacing: 8,
runSpacing: 8,
direction: Axis.horizontal,
textDirection: TextDirection.ltr,
children: _searchController.historyList
.map(
(item) => SearchText(
text: item,
onTap: _searchController.onClickKeyword,
onLongPress: _searchController.onLongSelect,
),
)
.toList(),
delegate: SliverChildBuilderDelegate(
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
childCount: list.length,
(context, index) => SearchText(
text: list[index],
onTap: _searchController.onClickKeyword,
onLongPress: _searchController.onLongSelect,
fontSize: 14,
height: 1,
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 8,
),
),
),
),
],
),
@@ -393,28 +379,58 @@ class _SearchPageState extends State<SearchPage> {
);
}
Widget _exportHistory(ThemeData theme) => SizedBox(
width: 34,
height: 34,
child: IconButton(
iconSize: 22,
tooltip: '导入/导出历史记录',
icon: Icon(
Icons.import_export_outlined,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
style: IconButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () => showImportExportDialog<List>(
context,
title: '历史记录',
toJson: () => jsonEncode(_searchController.historyList),
fromJson: (json) {
final list = List<String>.from(json);
_searchController.historyList.value = list;
GStorage.historyWord.put('cacheList', list);
return true;
Widget get _recordBtn => Obx(
() {
bool enable = _searchController.recordSearchHistory.value;
return IconButton(
iconSize: 22,
tooltip: enable ? '记录搜索' : '无痕搜索',
icon: DisabledIcon(
disable: !enable,
child: Icon(
Icons.history,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
),
style: const ButtonStyle(
visualDensity: .comfortable,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(.zero),
),
onPressed: () {
enable = !enable;
_searchController.recordSearchHistory.value = enable;
GStorage.setting.put(
SettingBoxKey.recordSearchHistory,
enable,
);
},
),
);
},
);
Widget get _exportBtn => IconButton(
iconSize: 22,
tooltip: '导入/导出历史记录',
icon: Icon(
Icons.import_export_outlined,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
style: const ButtonStyle(
visualDensity: .comfortable,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(.zero),
),
onPressed: () => showImportExportDialog<List>(
context,
title: '历史记录',
toJson: () => jsonEncode(_searchController.historyList),
fromJson: (json) {
final list = List<String>.from(json);
_searchController.historyList.value = list;
GStorage.historyWord.put('cacheList', list);
return true;
},
),
);
@@ -423,23 +439,19 @@ class _SearchPageState extends State<SearchPage> {
bool isTrending,
) {
return switch (loadingState) {
Success(:final response) =>
response.list?.isNotEmpty == true
? LayoutBuilder(
builder: (context, constraints) => HotKeyword(
width: constraints.maxWidth,
hotSearchList: response.list!,
onClick: _searchController.onClickKeyword,
),
)
: const SizedBox.shrink(),
Error(:final errMsg) => errorWidget(
Success(:final response) when (response.list?.isNotEmpty ?? false) =>
SliverHotKeyword(
hotSearchList: response.list!,
onClick: _searchController.onClickKeyword,
),
Error(:final errMsg) => HttpError(
safeArea: false,
errMsg: errMsg,
onReload: isTrending
? _searchController.queryTrendingList
: _searchController.queryRecommendList,
),
_ => const SizedBox.shrink(),
_ => const SliverToBoxAdapter(),
};
}
}

View File

@@ -1,87 +1,215 @@
import 'package:PiliPlus/models_new/search/search_trending/list.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/string_ext.dart';
import 'package:PiliPlus/utils/image_utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show
ContainerRenderObjectMixin,
MultiChildLayoutParentData,
RenderBoxContainerDefaultsMixin,
BoxHitTestResult;
class HotKeyword extends StatelessWidget {
final double width;
class SliverHotKeyword extends StatelessWidget {
final List<SearchTrendingItemModel> hotSearchList;
final Function? onClick;
const HotKeyword({
const SliverHotKeyword({
super.key,
required double width,
required this.hotSearchList,
this.onClick,
}) : width = width / 2 - 4;
});
@override
Widget build(BuildContext context) {
late final style = TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.outline,
color: ColorScheme.of(context).outline,
);
return Wrap(
runSpacing: 0.4,
spacing: 5.0,
children: [
for (final i in hotSearchList)
SizedBox(
width: width,
child: Material(
type: MaterialType.transparency,
borderRadius: const BorderRadius.all(Radius.circular(3)),
child: InkWell(
late final cacheHeight = (MediaQuery.devicePixelRatioOf(context) * 15.0)
.round();
return SliverToBoxAdapter(
child: _HotKeywordGrid(
mainAxisSpacing: 5,
crossAxisSpacing: 0.4,
crossAxisCount: 2,
children: hotSearchList
.map(
(i) => Material(
type: MaterialType.transparency,
borderRadius: const BorderRadius.all(Radius.circular(3)),
onTap: () => onClick?.call(i.keyword),
child: Padding(
padding: const EdgeInsets.only(left: 2, right: 10),
child: Tooltip(
message: i.keyword,
child: Row(
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 5, 0, 5),
child: Text(
i.keyword!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(fontSize: 14),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(3)),
onTap: () => onClick?.call(i.keyword),
child: Padding(
padding: const EdgeInsets.only(left: 2, right: 10),
child: Tooltip(
message: i.keyword,
child: Row(
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 5, 0, 5),
child: Text(
i.keyword!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(fontSize: 14),
),
),
),
),
if (!i.icon.isNullOrEmpty)
Padding(
padding: const EdgeInsets.only(left: 4),
child: CachedNetworkImage(
height: 15,
memCacheHeight: 15.cacheSize(context),
imageUrl: ImageUtils.thumbnailUrl(i.icon!),
placeholder: (_, _) => const SizedBox.shrink(),
),
)
else if (i.showLiveIcon == true)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(
'assets/images/live/live.gif',
width: 48,
height: 15,
cacheHeight: 15.cacheSize(context),
),
)
else if (i.recommendReason?.isNotEmpty == true)
Text(i.recommendReason!, style: style),
],
if (!i.icon.isNullOrEmpty)
Padding(
padding: const EdgeInsets.only(left: 4),
child: CachedNetworkImage(
height: 15,
memCacheHeight: cacheHeight,
imageUrl: ImageUtils.thumbnailUrl(i.icon!),
placeholder: (_, _) => const SizedBox.shrink(),
),
)
else if (i.showLiveIcon == true)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Image.asset(
'assets/images/live/live.gif',
width: 48,
height: 15,
cacheHeight: cacheHeight,
),
)
else if (i.recommendReason?.isNotEmpty == true)
Text(i.recommendReason!, style: style),
],
),
),
),
),
),
),
),
],
)
.toList(),
),
);
}
}
class _HotKeywordGrid extends MultiChildRenderObjectWidget {
const _HotKeywordGrid({
required this.crossAxisCount,
this.mainAxisSpacing = 0.0,
this.crossAxisSpacing = 0.0,
required super.children,
}) : assert(crossAxisCount > 0),
assert(mainAxisSpacing >= 0.0),
assert(crossAxisSpacing >= 0.0);
final int crossAxisCount;
final double mainAxisSpacing;
final double crossAxisSpacing;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderHotKeywordGrid(
crossAxisCount: crossAxisCount,
mainAxisSpacing: mainAxisSpacing,
crossAxisSpacing: crossAxisSpacing,
);
}
@override
void updateRenderObject(
BuildContext context,
_RenderHotKeywordGrid renderObject,
) {
renderObject
..crossAxisCount = crossAxisCount
..mainAxisSpacing = mainAxisSpacing
..crossAxisSpacing = crossAxisSpacing;
}
}
class _RenderHotKeywordGrid extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
_RenderHotKeywordGrid({
required int crossAxisCount,
required double mainAxisSpacing,
required double crossAxisSpacing,
}) : _crossAxisCount = crossAxisCount,
_mainAxisSpacing = mainAxisSpacing,
_crossAxisSpacing = crossAxisSpacing;
int _crossAxisCount;
int get crossAxisCount => _crossAxisCount;
set crossAxisCount(int value) {
if (_crossAxisCount == value) return;
_crossAxisCount = value;
markNeedsLayout();
}
double _mainAxisSpacing;
double get mainAxisSpacing => _mainAxisSpacing;
set mainAxisSpacing(double value) {
if (_mainAxisSpacing == value) return;
_mainAxisSpacing = value;
markNeedsLayout();
}
double _crossAxisSpacing;
double get crossAxisSpacing => _crossAxisSpacing;
set crossAxisSpacing(double value) {
if (_crossAxisSpacing == value) return;
_crossAxisSpacing = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
@override
void performLayout() {
final constraints = this.constraints;
final childWidth =
(constraints.maxWidth - mainAxisSpacing * (crossAxisCount - 1)) /
crossAxisCount;
final c = BoxConstraints(maxWidth: childWidth);
var child = firstChild;
double? childHeight;
int index = 0;
while (child != null) {
if (childHeight == null) {
childHeight = (child..layout(c, parentUsesSize: true)).size.height;
} else {
child.layout(c);
}
final parentData = child.parentData as MultiChildLayoutParentData
..offset = Offset(
(childWidth + mainAxisSpacing) * (index % crossAxisCount),
(childHeight + crossAxisSpacing) * (index ~/ crossAxisCount),
);
child = parentData.nextSibling;
index++;
}
final row = (index / crossAxisCount).ceil();
size = constraints.constrainDimensions(
constraints.maxWidth,
row * childHeight! + crossAxisSpacing * (row - 1),
);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}

View File

@@ -27,7 +27,7 @@ class SearchText extends StatelessWidget {
@override
Widget build(BuildContext context) {
late final colorScheme = Theme.of(context).colorScheme;
late final colorScheme = ColorScheme.of(context);
final hasLongPress = onLongPress != null;
return Material(
color: bgColor ?? colorScheme.onInverseSurface,