From c01318c06622d27d5a0744430a5f8a4aff831ea6 Mon Sep 17 00:00:00 2001 From: My-Responsitories <107370289+My-Responsitories@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:25:44 +0800 Subject: [PATCH] feat: sliver wrap (#1858) * feat: sliver wrap * opt: list * update --------- Co-authored-by: dom --- .../widgets/loading_widget/http_error.dart | 7 +- lib/common/widgets/sliver_wrap.dart | 352 +++++++++++++ lib/pages/search/view.dart | 474 +++++++++--------- lib/pages/search/widgets/hot_keyword.dart | 248 ++++++--- lib/pages/search/widgets/search_text.dart | 2 +- 5 files changed, 789 insertions(+), 294 deletions(-) create mode 100644 lib/common/widgets/sliver_wrap.dart diff --git a/lib/common/widgets/loading_widget/http_error.dart b/lib/common/widgets/loading_widget/http_error.dart index f657033d3..f726b68e5 100644 --- a/lib/common/widgets/loading_widget/http_error.dart +++ b/lib/common/widgets/loading_widget/http_error.dart @@ -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), ], ); } diff --git a/lib/common/widgets/sliver_wrap.dart b/lib/common/widgets/sliver_wrap.dart new file mode 100644 index 000000000..a2abcd3e6 --- /dev/null +++ b/lib/common/widgets/sliver_wrap.dart @@ -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 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 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, + ); + } + } +} diff --git a/lib/pages/search/view.dart b/lib/pages/search/view.dart index 58df6282f..d64163a06 100644 --- a/lib/pages/search/view.dart +++ b/lib/pages/search/view.dart @@ -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 { 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 { ); } + @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 { ) .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 { fontSize: 13, color: outline, ); - return Padding( + return SliverPadding( padding: EdgeInsets.fromLTRB( 10, !isTrending && (isPortrait || _searchController.enableTrending) @@ -193,23 +200,28 @@ class _SearchPageState extends State { 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 { ], ), ), - ), - ], - ) - : 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 { ), ), ), - ), - ], + ], + ), ), ), Obx( @@ -275,14 +286,16 @@ class _SearchPageState extends State { ); } - 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 { 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 { ), 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 { ); } - 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( - context, - title: '历史记录', - toJson: () => jsonEncode(_searchController.historyList), - fromJson: (json) { - final list = List.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( + context, + title: '历史记录', + toJson: () => jsonEncode(_searchController.historyList), + fromJson: (json) { + final list = List.from(json); + _searchController.historyList.value = list; + GStorage.historyWord.put('cacheList', list); + return true; + }, ), ); @@ -423,23 +439,19 @@ class _SearchPageState extends State { 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(), }; } } diff --git a/lib/pages/search/widgets/hot_keyword.dart b/lib/pages/search/widgets/hot_keyword.dart index 793efad4f..d7bb04ce6 100644 --- a/lib/pages/search/widgets/hot_keyword.dart +++ b/lib/pages/search/widgets/hot_keyword.dart @@ -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 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, + RenderBoxContainerDefaultsMixin { + _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); + } +} diff --git a/lib/pages/search/widgets/search_text.dart b/lib/pages/search/widgets/search_text.dart index 83a5f2e38..cd8958cc8 100644 --- a/lib/pages/search/widgets/search_text.dart +++ b/lib/pages/search/widgets/search_text.dart @@ -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,