Skip to content

Instantly share code, notes, and snippets.

@kerberjg
Created October 8, 2024 23:39
Show Gist options
  • Save kerberjg/c9cd4c4876c5b921ec882a1a6a092626 to your computer and use it in GitHub Desktop.
Save kerberjg/c9cd4c4876c5b921ec882a1a6a092626 to your computer and use it in GitHub Desktop.
Animated scrollwheel with haptic feedback~ (Flutter)
// Mouse scrolwheel widget
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const double kScrollInertia = 0.9;
const double kScrollVelocityMin = 0.01;
const int kScrollBeanWidthFraction = 6;
class ScrollBean extends StatelessWidget {
final Function(double) onScroll;
ScrollBean({
super.key,
required this.onScroll
});
double _scrollVelocity = 0;
@override
Widget build(BuildContext context) {
return
AspectRatio(
aspectRatio: 1.0/kScrollBeanWidthFraction.toDouble(),
child:
Container(
decoration: BoxDecoration(
color: CupertinoColors.quaternaryLabel,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white,
width: 2
)
),
child: LayoutBuilder(
// builder: (context, constraints) => GestureDetector(
// onVerticalDragUpdate: (details) {
// final double y = (details.primaryDelta ?? 0) / constraints.maxHeight;
// print("Scroll: ${y}");
// onScroll(y);
// },
// child: CustomPaint(
// painter: _ScrollWheelPainter(),
// ),
// )
// Instead of drag, detect fling
builder: (context, constraints) => RawGestureDetector(
gestures: {
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
ScrollPhysics physics = BouncingScrollPhysics();
// Set up scroll physics
instance
..maxFlingVelocity = physics.maxFlingVelocity
..minFlingVelocity = physics.minFlingVelocity
..minFlingDistance = physics.minFlingDistance
..onStart = (details) {
print("Start: ${details.localPosition.dx}, ${details.localPosition.dy}");
}
..onUpdate = (details) {
final double y = (details.primaryDelta ?? 0) / constraints.maxHeight;
// print("Scroll: ${y}");
onScroll(y);
_onScrollUpdate(y);
}
..onEnd = (details) {
// print("End velocity: ${details.primaryVelocity}");
// instance.isFlingGesture(estimate, kind)
if(details.primaryVelocity != null && details.primaryVelocity!.abs() > physics.minFlingVelocity) {
// print("Fling start!");
_scrollVelocity = details.primaryVelocity! / constraints.maxHeight;
WidgetsBinding.instance.addPostFrameCallback((timestamp) => _onFlingFrame(), debugLabel: 'scrollspotFlingFrame');
WidgetsBinding.instance.ensureVisualUpdate();
}
}
..velocityTrackerBuilder = (PointerEvent event) => VelocityTracker.withKind(PointerDeviceKind.touch)
;
}
)
},
child: ListenableBuilder(
listenable: _scrollBeanVisualOffset,
builder: (context, value) => CustomPaint(
isComplex: false,
willChange: true,
painter: _ScrollBeanPainter(),
),
),
)
),
),
);
}
void _onFlingFrame([Duration duration = const Duration(milliseconds: 16)]) {
final double t = duration.inMilliseconds / 1000.0;
final double dist = _scrollVelocity * t;
// print("Fling frame! t: $t, velocity: $_scrollVelocity, dist: $dist");
onScroll(dist);
_onScrollUpdate(dist);
// reduce velocity by (intertia * time since last frame)
_scrollVelocity *= kScrollInertia;
// check if ended
if(_scrollVelocity.abs() < kScrollVelocityMin) {
_scrollVelocity = 0;
} else {
// schedule next frame
WidgetsBinding.instance.addPostFrameCallback((timestamp) => _onFlingFrame());
WidgetsBinding.instance.ensureVisualUpdate();
}
}
/// used to update the visual representation of the scroll wheel
void _onScrollUpdate(double y) {
const threshold = kScrollBeanVisualOffsetThreshold;
_scrollBeanVisualOffset.value += y;
print("Visual offset: ${_scrollBeanVisualOffset.value} / $threshold");
// trigger frame render
WidgetsBinding.instance.ensureVisualUpdate();
if(_scrollBeanVisualOffset.value.abs() >= threshold) {
// play feedback (haptics + sound)
HapticFeedback.selectionClick();
// SystemSound.play(SystemSoundType.click); // TODO: make a PR for a softer SystemSoundType
// reset counter
_scrollBeanVisualOffset.value -= threshold * _scrollBeanVisualOffset.value.sign;
}
}
}
final ValueNotifier<double> _scrollBeanVisualOffset = ValueNotifier<double>(0);
const int kScrollBeanDotCount = 18;
const double kScrollBeanVisualOffsetThreshold = (1 / kScrollBeanDotCount);
/// Draws horizontal lines in a column to represent a scroll wheel
class _ScrollBeanPainter extends CustomPainter {
final _paint = Paint()
..color = CupertinoColors.tertiaryLabel.withOpacity(0.1)
..strokeWidth = 2;
@override
void paint(Canvas canvas, Size size) {
final double lineWidth = size.width / 2;
final double heightOffset = lineWidth;
final double totalHeight = size.height - heightOffset;
final double lineInterval = totalHeight / kScrollBeanDotCount;
final dots = List<double>.filled(kScrollBeanDotCount, 0);
for (int i = 0; i < kScrollBeanDotCount; i++) {
// the closer they are to the extremes, the closer togerther they are (cosine distribution)
double sinx = (i) / (kScrollBeanDotCount - 1);
sinx = (sinx + _scrollBeanVisualOffset.value ) % 1; // add visual offset
double y = 1 - cos(sinx * pi * 1);
y = y / 2;
print("sin(${sinx.toStringAsFixed(2)} * pi) = ${y.toStringAsFixed(2)}");
dots[i] = y;
}
final double x = size.width / 2 - lineWidth / 2;
for (int i = 0; i < kScrollBeanDotCount - 1; i++) {
double y = dots[i] * totalHeight;
double sinx = (i) / (kScrollBeanDotCount - 1);
sinx = (sinx + _scrollBeanVisualOffset.value ) % 1; // add visual offset
double opacity = pow(sin(sinx * pi * 1), 1.25).toDouble() * 0.1;
print("Opacity: $opacity");
_paint.color = _paint.color.withOpacity(opacity);
canvas.drawLine(
Offset(x, y + heightOffset / 2),
Offset(x + lineWidth, y + heightOffset / 2),
_paint
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment