Skip to content
Closed
153 changes: 41 additions & 112 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ class _CoverflowDemoScreenState extends State<CoverflowDemoScreen> {
double _shadowElevation = 8.0;
double _cardCornerRadiusValue = 24.0;

// Page indicator settings
double _indicatorDotSize = 8.0;
double _indicatorDotSpacing = 12.0;
bool _indicatorUseThemeColor = true;

Axis _scrollDirection = Axis.horizontal;
bool _useCustomWidth = false;
double _carouselWidth = 340.0;
Expand Down Expand Up @@ -301,9 +306,13 @@ class _CoverflowDemoScreenState extends State<CoverflowDemoScreen> {
const SizedBox(height: 24),

// Dynamic Liquid Page Indicator
_CoverflowPageIndicator(
CoverflowPageIndicator(
controller: _controller,
itemCount: _demoCards.length,
pageListenable: _controller.pageListenable,
activeColor: _indicatorUseThemeColor
? Theme.of(context).colorScheme.primary
: Colors.white,
dotSpacing: _indicatorDotSpacing,
onTap: (index) {
_controller.animateTo(index);
},
Expand Down Expand Up @@ -478,6 +487,36 @@ class _CoverflowDemoScreenState extends State<CoverflowDemoScreen> {
),
suffix: 'px',
),
const Divider(
height: 32,
color: Colors.white24,
),
_GlassSlider(
title: 'Indicator Dot Size',
value: _indicatorDotSize,
min: 4.0,
max: 16.0,
onChanged: (val) =>
setState(() => _indicatorDotSize = val),
suffix: 'px',
),
_GlassSlider(
title: 'Indicator Dot Spacing',
value: _indicatorDotSpacing,
min: 4.0,
max: 24.0,
onChanged: (val) => setState(
() => _indicatorDotSpacing = val,
),
suffix: 'px',
),
_GlassSwitch(
title: 'Theme-colored Active Dot',
value: _indicatorUseThemeColor,
onChanged: (val) => setState(
() => _indicatorUseThemeColor = val,
),
),
] else if (_configTab == 1) ...[
// Tab 1: Motion settings
_GlassSwitch(
Expand Down Expand Up @@ -709,116 +748,6 @@ class _AmbientBackdrop extends StatelessWidget {
}
}

/// Liquid sliding active pill page indicator
class _CoverflowPageIndicator extends StatelessWidget {
final int itemCount;
final ValueNotifier<double> pageListenable;
final void Function(int) onTap;

const _CoverflowPageIndicator({
required this.itemCount,
required this.pageListenable,
required this.onTap,
});

@override
Widget build(BuildContext context) {
const double dotSize = 8.0;
const double spacing = 12.0;
const double step = dotSize + spacing;

return ValueListenableBuilder<double>(
valueListenable: pageListenable,
builder: (context, page, _) {
final double t = page - page.floor();
final int floor = page.floor();

final double activeLeft;
final double activeWidth;

if (t < 0.5) {
activeLeft = (floor % itemCount) * step;
activeWidth = dotSize + (t / 0.5) * step;
} else {
activeLeft = (floor % itemCount) * step + ((t - 0.5) / 0.5) * step;
activeWidth = dotSize + (1.0 - (t - 0.5) / 0.5) * step;
}

final int indexA = floor % itemCount;
final int indexB = (floor + 1) % itemCount;

final double left;
final double width;

if (indexB == 0 && t > 0.0) {
if (t < 0.5) {
left = indexA * step;
width = dotSize + (t / 0.5) * step;
} else {
left = 0;
width = dotSize + (1.0 - (t - 0.5) / 0.5) * step;
}
} else {
left = activeLeft;
width = activeWidth;
}

return Container(
height: 24,
alignment: Alignment.center,
child: SizedBox(
width: itemCount * dotSize + (itemCount - 1) * spacing,
height: dotSize,
child: Stack(
clipBehavior: Clip.none,
children: [
...List.generate(itemCount, (i) {
return Positioned(
left: i * step,
top: 0,
width: dotSize,
height: dotSize,
child: GestureDetector(
onTap: () => onTap(i),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
),
),
),
);
}),
Positioned(
left: left,
top: 0,
width: width,
height: dotSize,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dotSize / 2),
gradient: const LinearGradient(
colors: [Colors.pinkAccent, Colors.purpleAccent],
),
boxShadow: [
BoxShadow(
color: Colors.pinkAccent.withValues(alpha: 0.4),
blurRadius: 8,
spreadRadius: 1,
),
],
),
),
),
],
),
),
);
},
);
}
}

/// Premium Play Button with dynamic scale on press
class _PremiumPlayButton extends StatefulWidget {
final String title;
Expand Down
1 change: 1 addition & 0 deletions lib/coverflow_carousel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ library;

export 'src/coverflow_carousel.dart';
export 'src/coverflow_carousel_controller.dart';
export 'src/coverflow_page_indicator.dart';
113 changes: 113 additions & 0 deletions lib/src/coverflow_page_indicator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'coverflow_carousel_controller.dart';

const double _tapTargetSize = 40.0;

/// Shows the current page as a sliding pill over dots.
///
/// Listens to [controller]'s [ValueNotifier] and animates the active
/// indicator between pages as the carousel scrolls.
class CoverflowPageIndicator extends StatelessWidget {
const CoverflowPageIndicator({
super.key,
required this.controller,
required this.itemCount,
this.activeColor = Colors.white,
this.inactiveColor = Colors.white38,
this.dotSize = 8.0,
this.dotSpacing = 12.0,
this.onTap,
}) : assert(itemCount >= 0),
assert(dotSize > 0),
assert(dotSpacing >= 0),
assert(_tapTargetSize >= dotSize);

final CoverflowCarouselController controller;
final int itemCount;
final Color activeColor;
final Color inactiveColor;
final double dotSize;
final double dotSpacing;
final void Function(int index)? onTap;

@override
Widget build(BuildContext context) {
final step = dotSize + dotSpacing;

return ValueListenableBuilder<double>(
valueListenable: controller.pageListenable,
builder: (context, page, _) {
if (itemCount <= 0) return const SizedBox.shrink();

final count = itemCount;
final clamped = page.clamp(0.0, count.toDouble() - 1e-9).toDouble();
final floor = clamped.floor();
final t = clamped - floor;
final indexB = (floor + 1) % count;
final isWrapping = count > 1 && indexB == 0 && floor == count - 1;

return SizedBox(
width: count * dotSize + (count - 1) * dotSpacing,
height: dotSize + 8,
child: Stack(
clipBehavior: Clip.none,
children: [
for (final i in List.generate(count, (i) => i))
Positioned(
left: i * step - (_tapTargetSize - dotSize) / 2,
top: 4 - (_tapTargetSize - dotSize) / 2,
width: _tapTargetSize,
height: _tapTargetSize,
child: GestureDetector(
onTap: onTap != null ? () => onTap!(i) : null,
behavior: HitTestBehavior.opaque,
child: Center(
child: Container(
width: dotSize,
height: dotSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: inactiveColor,
),
),
),
),
),
if (isWrapping && t > 0.5)
_buildActivePill(
left: 0,
width: dotSize + (1.0 - (t - 0.5) / 0.5) * step,
)
else
_buildActivePill(
left: floor * step + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step),
width:
dotSize +
(t < 0.5
? (t / 0.5) * step
: (1.0 - (t - 0.5) / 0.5) * step),
),
],
),
);
},
);
}

Widget _buildActivePill({required double left, required double width}) {
return Positioned(
left: left,
top: 4,
width: width,
height: dotSize,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(dotSize / 2),
),
),
),
);
}
}
Loading
Loading