Skip to content

Instantly share code, notes, and snippets.

@CoderNamedHendrick
Last active June 7, 2024 08:00
Show Gist options
  • Save CoderNamedHendrick/e94238c8203a27c2ecd934cc2fe12fcd to your computer and use it in GitHub Desktop.
Save CoderNamedHendrick/e94238c8203a27c2ecd934cc2fe12fcd to your computer and use it in GitHub Desktop.
Detect widget on screen
import 'package:flutter/material.dart';
// Records
typedef Position = ({double? x, double? y});
typedef VisibleArea = ({Position topLeft, Position bottomRight});
// Callbacks
typedef SizeCallback = void Function(({double? height, double? width}));
typedef PositionCallback = void Function(Position);
typedef VisibleAreaCallback = void Function(VisibleArea area);
const _kDefaultVisibleThreshold = 0.1;
class DetectableWidget extends StatefulWidget {
const DetectableWidget({
super.key,
required this.child,
this.ancestorToDetectIn,
this.size,
this.position,
this.visibleArea,
this.scrollOffsetNotifier,
this.onDetect,
this.visibleProgress,
this.visibilityThreshold = _kDefaultVisibleThreshold,
}) : assert(
visibilityThreshold >= 0 || visibilityThreshold <= 1,
'Visible threshold must be between 0 and 1',
);
final Widget child;
final RenderObject? ancestorToDetectIn;
final SizeCallback? size;
final PositionCallback? position;
final VisibleAreaCallback? visibleArea;
final ValueNotifier<double>? scrollOffsetNotifier;
final ValueChanged<bool>? onDetect;
final ValueChanged<double>? visibleProgress;
final double
visibilityThreshold; // by how much do you want the progress gone before calling onDetect?
@override
State<DetectableWidget> createState() => _DetectableWidgetState();
}
class _DetectableWidgetState extends State<DetectableWidget> {
VisibleArea? visibleArea;
bool? prevVisibleStatus;
void _scrollOffsetListener() {
if (widget.scrollOffsetNotifier == null || visibleArea == null) {
return;
}
final scrollOffset = widget.scrollOffsetNotifier!.value;
final topYPosition = visibleArea!.topLeft.y!;
final bottomYPosition = visibleArea!.bottomRight.y!;
final visibilityProgress = (bottomYPosition - scrollOffset) / //
// basically height
(bottomYPosition - topYPosition);
widget.visibleProgress
?.call(double.parse(visibilityProgress.clamp(0, 1).toStringAsFixed(2)));
final isVisible = scrollOffset <= bottomYPosition;
if (prevVisibleStatus == isVisible) return;
if (prevVisibleStatus == false &&
visibilityProgress < widget.visibilityThreshold) return;
widget.onDetect?.call(isVisible);
prevVisibleStatus = isVisible;
}
@override
void initState() {
super.initState();
widget.scrollOffsetNotifier?.addListener(_scrollOffsetListener);
WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) {
_initialiseDetection();
_scrollOffsetListener();
});
}
@override
void didUpdateWidget(covariant DetectableWidget oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.scrollOffsetNotifier?.removeListener(_scrollOffsetListener);
widget.scrollOffsetNotifier?.addListener(_scrollOffsetListener);
WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) {
_initialiseDetection();
});
}
void _initialiseDetection() {
final renderBox = context.findRenderObject() as RenderBox?;
final positionTranslation = context
.findRenderObject()
?.getTransformTo(widget.ancestorToDetectIn)
.getTranslation();
widget.position
?.call((x: positionTranslation?.x, y: positionTranslation?.y));
widget.size?.call(
(width: renderBox?.size.width, height: renderBox?.size.height),
);
visibleArea = (
topLeft: (x: positionTranslation?.x, y: positionTranslation?.y),
bottomRight: (
x: (NullableNum(positionTranslation?.x) +
NullableNum(renderBox?.size.width))
?.toDouble(),
y: (NullableNum(positionTranslation?.y) +
NullableNum(renderBox?.size.height))
?.toDouble(),
),
);
widget.visibleArea?.call(visibleArea!);
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
extension type const NullableNum(num? i) {
num? operator +(NullableNum other) {
if (i == null) return null;
if (other.i == null) return null;
return i! + other.i!;
}
num? operator -(NullableNum other) {
if (i == null) return null;
if (other.i == null) return null;
return i! - other.i!;
}
}
import 'package:detect_object_poc/detectable_widget.dart';
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return const Scaffold(body: _ScaffoldBody());
}
}
class _ScaffoldBody extends StatefulWidget {
const _ScaffoldBody();
@override
State<_ScaffoldBody> createState() => _ScaffoldBodyState();
}
class _ScaffoldBodyState extends State<_ScaffoldBody> {
final scrollKey = GlobalKey();
RenderObject? scrollRenderObject;
final scrollController = ScrollController();
final scrollOffset = ValueNotifier<double>(0);
double visibleProgress = 0;
@override
void initState() {
super.initState();
scrollController.addListener(() {
if (!scrollController.hasClients) return;
scrollOffset.value = scrollController.offset;
});
WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) {
scrollRenderObject = (scrollKey.currentContext?.findRenderObject());
});
}
@override
void didUpdateWidget(covariant _ScaffoldBody oldWidget) {
super.didUpdateWidget(oldWidget);
WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) {
scrollRenderObject = (scrollKey.currentContext?.findRenderObject());
});
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Align(
alignment: const Alignment(0.8, -0.8),
child: FloatingActionButton.large(
onPressed: () {},
child: Text(
visibleProgress.toString(),
),
),
),
SingleChildScrollView(
controller: scrollController,
child: Column(
key: scrollKey,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const _Dummy(),
DetectableWidget(
scrollOffsetNotifier: scrollOffset,
ancestorToDetectIn: scrollRenderObject,
onDetect: (isVisible) {
late final SnackBar snackBar;
if (isVisible) {
snackBar = const SnackBar(
content: Text('I\'m Visible'),
backgroundColor: Colors.green,
duration: Duration(milliseconds: 300),
);
} else {
snackBar = const SnackBar(
content: Text('I\'m not Visible'),
backgroundColor: Colors.red,
duration: Duration(milliseconds: 300),
);
}
ScaffoldMessenger.of(context).showSnackBar(snackBar);
},
visibilityThreshold: 0.01,
visibleProgress: (progress) {
setState(() {
visibleProgress = progress;
});
},
child: Container(
height: 300,
width: 500,
color: Colors.indigo,
),
),
...List.generate(30, (_) => const _Dummy()),
],
),
),
],
);
}
}
class _Dummy extends StatelessWidget {
const _Dummy();
double get notZeroToOne {
final random = Random().nextDouble();
if (random < 0.5) return random + 0.2;
return random;
}
@override
Widget build(BuildContext context) {
return Container(
height: notZeroToOne * 300,
width: notZeroToOne * 300,
color: Colors.amber.withAlpha((notZeroToOne * 255).toInt()),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment