0

I'm trying to create a parallax background in a Flutter app, and the most efficient way to build it is to use a Stack with the image filling the screen as a background and then my list on top. The image is tiled with an ImageRepeat set on the Y axis. The plan is to then offset the origin of the tile in sync with the ScrollController I'm using for my list. I can then adjust the origin of the tiled image to create the parallax effect. It should be really simple. Here's some code for context:

Stack(
          children: [
            SizedBox.expand(
              child: Image(
                image: AssetImage('assets/images/tiled_background_leaf.jpg'),
                repeat: ImageRepeat.repeatY,
              ),
            ),
            CustomScrollView(
              controller: _controller,
              slivers: [ ...

My problem is that Image does not have an offset property, or an origin position. I need some advice on the easiest way to do this. I've seen that there are custom painters, canvas methods etc, but they all seem massively over-complicated when there should be a more elegant solution within the Image widget, or possibly within another widget that would give me the same parallax effect.

Lee Probert
  • 10,308
  • 8
  • 43
  • 70
  • use `Padding` as a parent of your `Image` - the docs say: *"A widget that insets its child by the given padding."* – pskink Aug 23 '20 at 19:59
  • Unfortunately, that just resizes the bounds of the Image within its parent based on the edge insets set by the padding. I need the image to fill the screen, but set the start origin of the tile to something other than 0,0. – Lee Probert Aug 23 '20 at 20:29
  • That would produce the same results. I need a tiled repeating image across the entire screen but be able to change the origin. For example, start painting the image 100 pixels up from the top of the container but still fill the entire thing. – Lee Probert Aug 23 '20 at 20:55
  • i see it now, `Image` has `alignment` property - by default it is 'center' - change it with `FractionalOffset` (both axis range is 0..1) – pskink Aug 23 '20 at 21:12
  • That works! Thanks. Do you want to write it up, or shall I? – Lee Probert Aug 23 '20 at 21:44
  • Probably best to wait until I write a custom Widget that does the parallax using the offset value and a ScrollController. – Lee Probert Aug 23 '20 at 21:45
  • great it works, feel free to post a self answer :-) – pskink Aug 24 '20 at 05:07

1 Answers1

2

Thanks to @pskink for the answer to this (see comments above).

Here's some code for a dashboard that has a scrolling list of articles and the parallax scrolling tiled image as a background ...

class DashboardRoot extends StatefulWidget {
  DashboardRoot({Key key}) : super(key: key);

  @override
  _DashboardRootState createState() => _DashboardRootState();
}

class _DashboardRootState extends State<DashboardRoot> {
  int _currentIndex = 0;
  ScrollController _controller;

  double _offsetY = 0.0;

  _scrollListener() {
    setState(() {
      _offsetY = _controller.offset;
    });
  }

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      var state = Provider.of<ArticlesState>(context, listen: false);
      state.initArticleStream();
    });
    _controller = ScrollController();
    _controller.addListener(_scrollListener);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
        bottomNavigationBar: AppBottomNavigationBar(),
        body: Stack(
          children: [
            SizedBox.expand(
              child: Image(
                image: AssetImage('assets/images/tiled_background_leaf.jpg'),
                repeat: ImageRepeat.repeatY,
                alignment: FractionalOffset(0, (_offsetY / 1000) * -1),
              ),
            ),
            CustomScrollView(
              controller: _controller,
              slivers: [
                SliverAppBar(
                  elevation: 0.0,
                  floating: true,
                  expandedHeight: 120,
                  flexibleSpace: FlexibleSpaceBar(
                    title: Text(NavigationManager
                        .instance.menuItems[_currentIndex].title),
                  ),
                  actions: <Widget>[
                    IconButton(
                      icon: Icon(Icons.settings),
                      onPressed: () => {
                        locator<NavigationService>()
                            .navigateTo(SettingsNavigator.routeName)
                      },
                    ),
                    IconButton(
                      icon: Icon(Icons.menu),
                      onPressed: () => {RootScaffold.openDrawer(context)},
                    ),
                  ],
                ),
                Consumer<ArticlesState>(
                  builder: (context, state, child) {
                    final List<Article> list = state.articles;
                    if (list == null) {
                      return SliverToBoxAdapter(
                        child: Center(
                          child: CircularProgressIndicator(
                              backgroundColor: Colors.amber, strokeWidth: 1),
                        ),
                      );
                    } else if (list.length > 0) {
                      return SliverGrid(
                        gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                          maxCrossAxisExtent: 200.0,
                          mainAxisSpacing: 10.0,
                          crossAxisSpacing: 10.0,
                          childAspectRatio: 1.0,
                        ),
                        delegate: SliverChildBuilderDelegate(
                          (BuildContext context, int index) {
                            Article article = list[index];
                            return ArticleCell(
                                article: article,
                                cellTapHandler: () {
                                  Navigator.pushNamed(
                                      context, ArticleDetail.routeName,
                                      arguments: new ArticleDetailArguments(
                                          article.docId, article.heading));
                                });
                          },
                          childCount: list.length,
                        ),
                      );
                    } else {
                      return Center(
                        child: Text("No Articles"),
                      );
                    }
                  },
                ),
              ],
            ),
          ],
        ));
  }
}

Notice the Stack has the background image inside an expanded SizedBox so it fills the screen space. The layer above is the CustomScrollView which has the SliverGrid and other stuff.

The important bit is the Image:

child: Image(
                image: AssetImage('assets/images/tiled_background_leaf.jpg'),
                repeat: ImageRepeat.repeatY,
                alignment: FractionalOffset(0, (_offsetY / 1000) * -1),
              ),

and also the property _offsetY which is set by the ScrollController listener as the users scroll:

double _offsetY = 0.0;

  _scrollListener() {
    setState(() {
      _offsetY = _controller.offset;
    });
  }

The Image alignment property is used to set the alignment to top, centre, left etc. but it can also be an arbitrary offset. The FractionalOffset value is a range 0..1 but setting it as a larger number above or below zero is also absolutely fine. Because the image is also tiled using ImageRepeat.repeatY the origin of the tiled image is redrawn using alignment, and by messing around with the number, you can create a nice parallax scrolling effect.

Notice that FractionalOffset(0, (_offsetY / 1000) * -1) has the offset value divided by 1000 (this is your speed, and the higher the value the slower the parallax of the background (think of it as the distance between the two layers). Multiplying a number by -1 switches between a positive and negative number, and changes the direction of the parallax.

Lee Probert
  • 10,308
  • 8
  • 43
  • 70
  • 1
    btw instead of `Stack` you could use `DecoratedBox` or even ordinary `Container` together with `DecorationImage` – pskink Aug 26 '20 at 04:01
  • Thanks! I'm new to this, so still discovering all these different widgets. – Lee Probert Aug 26 '20 at 16:51
  • I also noticed that a tiled image will always shrink to its bounds, so if the image needs to be longer than the container you have to use a Transform.scale. Also tiling doesn't work at all with transparent PNG's. – Lee Probert Aug 26 '20 at 16:52
  • 1
    *"I also noticed that a tiled image will always shrink to its bounds"* - so i think the better option would be a custom `Decoration` class (used in `DecoratedBox` or `Container`) – pskink Aug 26 '20 at 23:51