feat(app): 接入首页轮播 banner
This commit is contained in:
1 parent
061aac6577
commit
7b927c794e
7 files changed
+469
-37
No files matched your search
@@ -0,0 +1,234 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/home_feed.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _carouselHeight = 154;
|
||||
const double _indicatorHeight = 24;
|
||||
const double _dotSize = 6;
|
||||
const double _activeDotWidth = 18;
|
||||
const double _viewportFraction = 0.94;
|
||||
const Duration _autoAdvanceInterval = Duration(milliseconds: 4500);
|
||||
const Duration _pageTransition = Duration(milliseconds: 520);
|
||||
|
||||
/// Auto-advancing, touch-pausable carousel for real `/app/home/feed` banners.
|
||||
class FeedBannerCarousel extends StatefulWidget {
|
||||
const FeedBannerCarousel({
|
||||
super.key,
|
||||
required this.banners,
|
||||
required this.onOpen,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
final List<FeedBanner> banners;
|
||||
final ValueChanged<FeedBanner> onOpen;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
|
||||
}
|
||||
|
||||
class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
|
||||
final PageController _controller = PageController(
|
||||
viewportFraction: _viewportFraction,
|
||||
);
|
||||
Timer? _timer;
|
||||
int _index = 0;
|
||||
bool _pressed = false;
|
||||
bool _tickerEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
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 FeedBannerCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.banners.length != widget.banners.length ||
|
||||
oldWidget.active != widget.active) {
|
||||
if (_index >= widget.banners.length) _index = 0;
|
||||
_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(_autoAdvanceInterval, (_) => _advance());
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (!_controller.hasClients || widget.banners.length < 2) return;
|
||||
final next = (_index + 1) % widget.banners.length;
|
||||
_controller.animateToPage(
|
||||
next,
|
||||
duration: _pageTransition,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_pressed = true;
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
void _resume() {
|
||||
_pressed = false;
|
||||
_syncTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.banners.isEmpty) return const SizedBox.shrink();
|
||||
return Listener(
|
||||
key: const Key('feed-banner-carousel'),
|
||||
onPointerDown: (_) => _pause(),
|
||||
onPointerUp: (_) => _resume(),
|
||||
onPointerCancel: (_) => _resume(),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _carouselHeight,
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() => _index = value),
|
||||
itemBuilder: (context, index) => _bannerCard(index),
|
||||
),
|
||||
),
|
||||
SizedBox(height: _indicatorHeight, child: _indicator(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bannerCard(int index) {
|
||||
final banner = widget.banners[index];
|
||||
final dramaId = banner.dramaId;
|
||||
final card = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(banner.imageUrl, showFallbackIcon: true),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x8A0B0C10)],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (dramaId != null)
|
||||
const Positioned(right: 14, bottom: 14, child: _PlayMark()),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: dramaId == null
|
||||
? card
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: Key('feed-banner-$index'),
|
||||
onTap: () => widget.onOpen(banner),
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Semantics(container: true, button: dramaId != null, child: page);
|
||||
}
|
||||
|
||||
Widget _indicator(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ExcludeSemantics(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < widget.banners.length; index++)
|
||||
Semantics(
|
||||
label: l10n.carouselSemantics(index + 1, widget.banners.length),
|
||||
selected: index == _index,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
key: Key('feed-banner-dot-$index'),
|
||||
width: index == _index ? _activeDotWidth : _dotSize,
|
||||
height: _dotSize,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: index == _index
|
||||
? AppColors.onPrimary
|
||||
: AppColors.muted,
|
||||
borderRadius: BorderRadius.circular(_dotSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayMark extends StatelessWidget {
|
||||
const _PlayMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.blue.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user