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.