Created
October 8, 2024 23:39
-
-
Save kerberjg/c9cd4c4876c5b921ec882a1a6a092626 to your computer and use it in GitHub Desktop.
Animated scrollwheel with haptic feedback~ (Flutter)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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