Search code examples
flutterdartflutter-layout

How to do reverse parallax for background image in Flutter?


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,
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}



Solution

  • enter image description here

    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 :

    enter image description here

    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: [