I saw this parallax animation on Far Cry 6 website and wanted to recreate it in Flutter. I read the docs and watched some tutorials but I am not able to achieve this design accurately. I want to make a mobile version like the website shows on Mobile Browser like this - https://i.sstatic.net/YDuwM.jpg
The image needs to be on top of one another and when I scroll, the bottom image should come from bottom to top in a reversed manner as the website. I am not able to figure out how to do this.
Please tell me how I can achieve this?
My Code -
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final height = MediaQuery.of(context).size.height;
final width = MediaQuery.of(context).size.width;
return Scaffold(
body: Material(
child: Stack(
children: [
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/1mP5aHQYJI5xw08eD2eV0W/7bc890d58c6bcf5efbe8713f147828ae/banner-webasset.jpg",
height: height,
// width: width,
fit: BoxFit.fitHeight,
),
SingleChildScrollView(
child: Column(
children: [
SizedBox(height: height),
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/7ad5MGeYDCekqMiS5qRZuK/1005c73ea409e895058d4273d93dcb1a/banner-background_alt.jpg",
height: height,
width: width,
fit: BoxFit.cover,
),
],
),
)
],
),
),
);
}
}
basic idea is to use ScrollMetrics
from ScrollNotification
:
parallax.dart:
class Parallax extends StatefulWidget {
const Parallax({Key? key, required this.childreen}) : super(key: key);
final List<Widget> childreen;
@override
State<Parallax> createState() => _ParallaxState();
}
class _ParallaxState extends State<Parallax> {
late double _pixel;
@override
void initState() {
// TODO: implement initState
super.initState();
_pixel = 0;
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notif) {
setState(() {
_pixel = notif.metrics.pixels;
});
return true;
},
child: SingleChildScrollView(
child: Column(
children: List.generate(
widget.childreen.length,
(index) => _ParalaxChild(
index: index,
pixel: _pixel,
childreenLenght: widget.childreen.length,
child: widget.childreen[index])),
),
),
);
}
}
class _ParalaxChild extends StatelessWidget {
final int index;
final Widget child;
final double pixel;
final int childreenLenght;
const _ParalaxChild(
{Key? key,
required this.child,
required this.index,
required this.pixel,
required this.childreenLenght})
: super(key: key);
@override
Widget build(BuildContext context) {
return ClipRect(
child: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Stack(
fit: StackFit.expand,
children: [
FractionalTranslation(
translation: Offset(0.0, -_calculateParallax(context)),
child: child),
const Positioned(
bottom: 200,
right: 20,
child: Text(
"Parallax",
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold),
)),
const Positioned(
bottom: 12,
right: 20,
left: 20,
child: Padding(
padding: EdgeInsets.all(20.0),
child: Text(
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
))
],
)),
);
}
double _calculateParallax(BuildContext context) {
final double height = MediaQuery.of(context).size.height;
final double pageHeight = height * index;
final double childPosition = (pageHeight - pixel) / height;
return childPosition.isNaN ? 0.0 : childPosition;
}
}
use it or you can customize :
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
double height = MediaQuery.of(context).size.height;
double width = MediaQuery.of(context).size.width;
return Scaffold(
body: Parallax(childreen: [
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/1mP5aHQYJI5xw08eD2eV0W/7bc890d58c6bcf5efbe8713f147828ae/banner-webasset.jpg",
height: height,
// width: width,
fit: BoxFit.fitHeight,
),
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/7ad5MGeYDCekqMiS5qRZuK/1005c73ea409e895058d4273d93dcb1a/banner-background_alt.jpg",
height: height,
width: width,
fit: BoxFit.fitHeight,
),
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/1mP5aHQYJI5xw08eD2eV0W/7bc890d58c6bcf5efbe8713f147828ae/banner-webasset.jpg",
height: height,
// width: width,
fit: BoxFit.fitHeight,
),
Image.network(
"https://staticctf.akamaized.net/J3yJr34U2pZ2Ieem48Dwy9uqj5PNUQTn/7ad5MGeYDCekqMiS5qRZuK/1005c73ea409e895058d4273d93dcb1a/banner-background_alt.jpg",
height: height,
width: width,
fit: BoxFit.fitHeight,
),
]),
);
}
}
edit For torn effect :
i create clipper for your reference you can always customize later:
class TornEffect extends CustomClipper<Path>{
///how much the torn effect will be
final int intensity;
TornEffect({required this.intensity});
@override
ui.Path getClip(ui.Size size) {
var path = Path();
//make sure path on left top corner
path.moveTo(0.0, 0.0);
var random = math.Random();
//to prevent blankSpace, move y to top (must always negative):
double ybase = -20.0;
//top:
double topProgress = 0.0;
while(topProgress < size.width){
bool curve = random.nextBool();
if(curve){
double cpx = topProgress + random.nextInt(intensity) * 0.5 * negativePositive();
double cpy = ybase + (random.nextInt(intensity) * 0.25 * negativePositive());
double x = topProgress + random.nextInt(intensity)* 1.0 * negativePositive();
double y = ybase + (random.nextInt(intensity) * 0.5 * negativePositive());
path.quadraticBezierTo(
cpx,
cpy,
x,
y
);
topProgress += x.abs();
}
else {
double x = topProgress + random.nextInt(intensity) * 1.0 * negativePositive();
double y = ybase + (random.nextInt(intensity) * 0.5 * negativePositive());
path.lineTo(x, y);
topProgress += x.abs();
}
}
//make sure top right corner got shape
path.lineTo(size.width, 0.0);
// line to bottom right corner
path.lineTo(size.width, size.height);
// you can build bottom rip effect later:
//double bottomProgress = 0.0;
//while(){}
// bottom left
path.lineTo(0.0, size.height);
//close it with another line
path.close();
return path;
}
double negativePositive(){
var random = math.Random();
bool negativePositive = random.nextBool();
double result = negativePositive ? 1.0: -1.0;
return result;
}
@override
bool shouldReclip(covariant CustomClipper<ui.Path> oldClipper) {
// TODO: implement shouldReclip
//return this != oldClipper;
// return false for performance, or rebuilt every scroll
return false;
}
}
on parallax.dart:
return ClipPath(
clipper: TornEffect(intensity: 21),
child: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Stack(
fit: StackFit.expand,
children: [