Files
ReelShort/lib/pages/recommend/widgets/home_hero_carousel.dart
T

300 lines
8.8 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/home_feed.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
/// 推荐页顶部静态头图轮播:封面 + 底部剧名/简介 + 轮播点。
class HomeHeroCarousel extends StatefulWidget {
const HomeHeroCarousel({
super.key,
required this.banners,
required this.dramaFor,
required this.onWatch,
this.active = true,
});
final List<FeedBanner> banners;
/// 按 banner 关联到的剧目;为空时只展示头图。
final Drama? Function(FeedBanner banner) dramaFor;
/// 点击头图进入播放。
final ValueChanged<Drama> onWatch;
final bool active;
/// 固定轮播高度;后台按该比例(屏宽 : height)出图即可铺满不裁切。
static const double height = 420;
@override
State<HomeHeroCarousel> createState() => _HomeHeroCarouselState();
}
class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
static const Duration _autoInterval = Duration(milliseconds: 4500);
static const Duration _pageDuration = Duration(milliseconds: 480);
static const double _dotSize = 4;
static const double _dotGap = 9;
/// 轮播点距底边的距离。
static const double _dotBottom = 14;
/// 文案区距底边:让出轮播点区域(底距 + 点直径)再留一段呼吸空间。
static const double _copyBottomInset = _dotBottom + _dotSize + 18;
/// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
static const int _basePage = 1000;
late final PageController _controller;
Timer? _timer;
int _page = 0;
int _index = 0;
bool _pressed = false;
bool _tickerEnabled = true;
/// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。
static int _initialPageOf(int bannerCount) =>
bannerCount > 1 ? bannerCount * _basePage : 0;
@override
void initState() {
super.initState();
_page = _initialPageOf(widget.banners.length);
_controller = PageController(initialPage: _page);
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final enabled = TickerMode.valuesOf(context).enabled;
if (enabled != _tickerEnabled) {
_tickerEnabled = enabled;
_syncTimer();
}
}
@override
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.banners.length != widget.banners.length) {
// 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点,
// 否则左滑第一下就到头了。
_page = _initialPageOf(widget.banners.length);
_index = 0;
_jumpToBasePage();
}
if (oldWidget.banners.length != widget.banners.length ||
oldWidget.active != widget.active) {
_syncTimer();
}
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
void _syncTimer() {
_timer?.cancel();
_timer = null;
final canAdvance =
mounted &&
widget.active &&
_tickerEnabled &&
!_pressed &&
widget.banners.length > 1;
if (!canAdvance) return;
_timer = Timer.periodic(_autoInterval, (_) => _advance());
}
void _advance() {
if (!_controller.hasClients || widget.banners.length < 2) return;
// 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。
_controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic);
}
/// 把 PageView 静默挪到虚拟起点:视觉上是同一张图,不会闪。
void _jumpToBasePage() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_controller.hasClients) return;
if (_controller.page?.round() != _page) _controller.jumpToPage(_page);
});
}
@override
Widget build(BuildContext context) {
if (widget.banners.isEmpty) return const SizedBox.shrink();
return SizedBox(
key: const Key('home-hero-carousel'),
height: HomeHeroCarousel.height,
child: Listener(
onPointerDown: (_) {
_pressed = true;
_timer?.cancel();
},
onPointerUp: (_) {
_pressed = false;
_syncTimer();
},
onPointerCancel: (_) {
_pressed = false;
_syncTimer();
},
child: Stack(
fit: StackFit.expand,
children: [
PageView.builder(
controller: _controller,
// 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。
itemCount: widget.banners.length > 1
? null
: widget.banners.length,
onPageChanged: (value) => setState(() {
_page = value;
final count = widget.banners.length;
_index = count == 0 ? 0 : value % count;
}),
itemBuilder: (context, page) =>
_bannerPage(context, page % widget.banners.length),
),
Positioned(
left: 0,
right: 0,
bottom: _dotBottom,
child: _dots(context),
),
],
),
),
);
}
Widget _bannerPage(BuildContext context, int index) {
final banner = widget.banners[index];
final drama = widget.dramaFor(banner);
return Stack(
fit: StackFit.expand,
children: [
RemoteImage(
banner.imageUrl,
fit: BoxFit.cover,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x99000000),
Color(0x00000000),
Color(0x66000000),
Color(0xB3000000),
],
stops: [0, 0.22, 0.62, 1],
),
),
),
if (drama != null)
Positioned(
left: 28,
right: 28,
bottom: _copyBottomInset,
child: _HeroCopy(key: Key('home-hero-copy-$index'), drama: drama),
),
if (drama != null)
Positioned.fill(
child: Material(
color: AppColors.transparent,
child: InkWell(
key: Key('home-hero-$index'),
highlightColor: AppColors.transparent,
onTap: () => widget.onWatch(drama),
),
),
),
],
);
}
Widget _dots(BuildContext context) {
if (widget.banners.length < 2) return const SizedBox.shrink();
return ExcludeSemantics(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < widget.banners.length; index++)
Container(
key: Key('home-hero-dot-$index'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: _dotGap / 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _index
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.4),
),
),
],
),
);
}
}
/// Hero 底部居中文案:剧名 + 简介。
class _HeroCopy extends StatelessWidget {
const _HeroCopy({super.key, required this.drama});
final Drama drama;
static const Color _titleCream = Color(0xFFE9D3B8);
@override
Widget build(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
return Column(
key: const Key('home-hero-copy'),
mainAxisSize: MainAxisSize.min,
children: [
Text(
drama.localizedTitle(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: _titleCream,
fontSize: 22,
fontWeight: FontWeight.w400,
height: 1.15,
shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)],
),
),
const SizedBox(height: 8),
Text(
drama.localizedSynopsis(lang),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.95),
fontSize: 15,
fontWeight: FontWeight.w400,
shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
),
),
],
);
}
}