Skip to content

Instantly share code, notes, and snippets.

@sgruhier
Forked from slightfoot/page_turn.dart
Last active July 5, 2022 13:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sgruhier/8141a63775ae23b4edaa9bcef2bae77f to your computer and use it in GitHub Desktop.
Save sgruhier/8141a63775ae23b4edaa9bcef2bae77f to your computer and use it in GitHub Desktop.
Page Turn Effect - By Simon Lightfoot. Replicating this behaviour. https://www.youtube.com/watch?v=JqvtZwIJMLo
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ExampleScreen extends StatefulWidget {
@override
_ExampleScreenState createState() => _ExampleScreenState();
}
class _ExampleScreenState extends State<ExampleScreen> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
value: 0.5,
duration: const Duration(milliseconds: 450),
vsync: this,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTap() {
if (_controller.status == AnimationStatus.dismissed || _controller.status == AnimationStatus.reverse) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return Material(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTap,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
const PageTurnImage(
amount: AlwaysStoppedAnimation(1.0),
image: NetworkImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/John_Masefield.djvu/page10-1024px-John_Masefield.djvu.jpg'),
),
PageTurnWidget(
amount: _controller,
child: AlicePage1(),
),
Positioned(
left: 0.0,
right: 0.0,
bottom: 0.0,
height: 48.0,
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
return Slider(
value: _controller.value,
onChanged: (double value) {
_controller.value = value;
},
);
},
),
),
],
),
),
);
}
}
class AlicePage1 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTextStyle.merge(
style: const TextStyle(fontSize: 16.0),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
"CHAPTER I",
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const Text(
"Down the Rabbit-Hole",
style: TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32.0),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Expanded(
child: Text("Alice was beginning to get very tired of sitting by her sister on the bank, and of"
" having nothing to do: once or twice she had peeped into the book her sister was "
"reading, but it had no pictures or conversations in it, `and what is the use of "
"a book,' thought Alice `without pictures or conversation?'"),
),
Container(
margin: const EdgeInsets.only(left: 12.0),
color: Colors.black26,
width: 160.0,
height: 220.0,
child: const Placeholder(),
),
],
),
const SizedBox(height: 16.0),
const Text(
"So she was considering in her own mind (as well as she could, for the hot day made her "
"feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be "
"worth the trouble of getting up and picking the daisies, when suddenly a White "
"Rabbit with pink eyes ran close by her.\n"
"\n"
"There was nothing so very remarkable in that; nor did Alice think it so very much out "
"of the way to hear the Rabbit say to itself, `Oh dear! Oh dear! I shall be "
"late!' (when she thought it over afterwards, it occurred to her that she ought to "
"have wondered at this, but at the time it all seemed quite natural); but when the "
"Rabbit actually took a watch out of its waistcoat-pocket, and looked at it, and then "
"hurried on, Alice started to her feet, for it flashed across her mind that she had "
"never before seen a rabbit with either a waistcoat-pocket, or a watch to take out "
"of it, and burning with curiosity, she ran across the field after it, and fortunately "
"was just in time to see it pop down a large rabbit-hole under the hedge.",
),
],
),
),
),
);
}
}
// -----------------
class PageTurnWidget extends StatefulWidget {
const PageTurnWidget({
Key? key,
required this.amount,
this.backgroundColor = const Color(0xFFFFFFCC),
required this.child,
}) : super(key: key);
final Animation<double> amount;
final Color backgroundColor;
final Widget child;
@override
_PageTurnWidgetState createState() => _PageTurnWidgetState();
}
class _PageTurnWidgetState extends State<PageTurnWidget> {
final _boundaryKey = GlobalKey();
ui.Image? _image;
@override
void didUpdateWidget(PageTurnWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.child != widget.child) {
_image = null;
}
}
void _captureImage(Duration timeStamp) async {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: pixelRatio);
setState(() => _image = image);
}
@override
Widget build(BuildContext context) {
if (_image != null) {
return CustomPaint(
painter: _PageTurnEffect(
amount: widget.amount,
image: _image!,
backgroundColor: widget.backgroundColor,
),
size: Size.infinite,
);
} else {
WidgetsBinding.instance.addPostFrameCallback(_captureImage);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final size = constraints.biggest;
return Stack(
clipBehavior: Clip.hardEdge,
children: <Widget>[
Positioned(
left: 1 + size.width,
top: 1 + size.height,
width: size.width,
height: size.height,
child: RepaintBoundary(
key: _boundaryKey,
child: widget.child,
),
),
],
);
},
);
}
}
}
class PageTurnImage extends StatefulWidget {
const PageTurnImage({
Key? key,
required this.amount,
required this.image,
this.backgroundColor = const Color(0xFFFFFFCC),
}) : super(key: key);
final Animation<double> amount;
final ImageProvider image;
final Color backgroundColor;
@override
_PageTurnImageState createState() => _PageTurnImageState();
}
class _PageTurnImageState extends State<PageTurnImage> {
ImageStream? _imageStream;
ImageInfo? _imageInfo;
bool _isListeningToStream = false;
ImageStreamListener? _imageListener;
@override
void initState() {
super.initState();
_imageListener = ImageStreamListener(_handleImageFrame);
}
@override
void dispose() {
_stopListeningToStream();
super.dispose();
}
@override
void didChangeDependencies() {
_resolveImage();
if (TickerMode.of(context)) {
_listenToStream();
} else {
_stopListeningToStream();
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(PageTurnImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.image != oldWidget.image) {
_resolveImage();
}
}
@override
void reassemble() {
_resolveImage(); // in case the image cache was flushed
super.reassemble();
}
void _resolveImage() {
final ImageStream newStream = widget.image.resolve(createLocalImageConfiguration(context));
_updateSourceStream(newStream);
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() => _imageInfo = imageInfo);
}
// Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was
// registered).
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream.key) return;
if (_isListeningToStream) _imageStream?.removeListener(_imageListener!);
_imageStream = newStream;
if (_isListeningToStream) _imageStream?.addListener(_imageListener!);
}
void _listenToStream() {
if (_isListeningToStream) return;
_imageStream?.addListener(_imageListener!);
_isListeningToStream = true;
}
void _stopListeningToStream() {
if (!_isListeningToStream) return;
_imageStream?.removeListener(_imageListener!);
_isListeningToStream = false;
}
@override
Widget build(BuildContext context) {
if (_imageInfo != null) {
return CustomPaint(
painter: _PageTurnEffect(
amount: widget.amount,
image: _imageInfo!.image,
backgroundColor: widget.backgroundColor,
),
size: Size.infinite,
);
} else {
return const SizedBox();
}
}
}
class _PageTurnEffect extends CustomPainter {
_PageTurnEffect({
required this.amount,
required this.image,
required this.backgroundColor,
this.radius = 0.18,
}) : super(repaint: amount);
final Animation<double> amount;
final ui.Image image;
final Color backgroundColor;
final double radius;
@override
void paint(ui.Canvas canvas, ui.Size size) {
final pos = amount.value;
final movX = (1.0 - pos) * 0.85;
final calcR = (movX < 0.20) ? radius * movX * 5 : radius;
final wHRatio = 1 - calcR;
final hWRatio = image.height / image.width;
final hWCorrection = (hWRatio - 1.0) / 2.0;
final w = size.width.toDouble();
final h = size.height.toDouble();
final c = canvas;
final shadowXf = (wHRatio - movX);
final shadowSigma = Shadow.convertRadiusToSigma(8.0 + (32.0 * (1.0 - shadowXf)));
final pageRect = Rect.fromLTRB(0.0, 0.0, w * shadowXf, h);
c.drawRect(pageRect, Paint()..color = backgroundColor);
c.drawRect(
pageRect,
Paint()
..color = Colors.black54
..maskFilter = MaskFilter.blur(BlurStyle.outer, shadowSigma),
);
final ip = Paint();
for (double x = 0; x < size.width; x++) {
final xf = (x / w);
final v = (calcR * (math.sin(math.pi / 0.5 * (xf - (1.0 - pos)))) + (calcR * 1.1));
final xv = (xf * wHRatio) - movX;
final sx = (xf * image.width);
final sr = Rect.fromLTRB(sx, 0.0, sx + 1.0, image.height.toDouble());
final yv = ((h * calcR * movX) * hWRatio) - hWCorrection;
final ds = (yv * v);
final dr = Rect.fromLTRB(xv * w, 0.0 - ds, xv * w + 1.0, h + ds);
c.drawImageRect(image, sr, dr, ip);
}
}
@override
bool shouldRepaint(_PageTurnEffect oldDelegate) {
return oldDelegate.image != image || oldDelegate.amount.value != amount.value;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment