I want to \'fade in\' and \'fade out\' a widget from SliverAppBar when user scrolls on the screen.
This is an example of what I want to do:
Here is my c
This is actually quite simple using ScrollController
and the Opacity
Widget. Here's a basic example:
https://gist.github.com/smkhalsa/ec33ec61993f29865a52a40fff4b81a2
This solution uses the bloc pattern with a StreamBuilder, in addition to a LayoutBuilder to get a measure of the height available for the first time flutter builds the widget. The solution is probably not perfect as a locking semaphore was needed to prevent flutter constantly rebuild the widget in the StreamBuilder. The solution does not rely on animations, so you can stop the swipe midway and have a partially visible AppBar and CircleAvatar & Text.
Initially, I attempted to create this effect with setState, that did not work since the state became dirty because the build was not finished when setState was called before LayoutBuilder's return statement.
I have separated the solution into three files. The first main.dart resembles mostly what nesscx posted, with changes making the widget stateful and using a custom widget which is shown in the second file.
import 'package:flutter/material.dart';
import 'flexible_header.dart'; // The code in the next listing
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fading out CircleAvatar',
theme: ThemeData(
primarySwatch: Colors.purple,
),
home: App());
}
}
class App extends StatefulWidget {
@override
_AppState createState() => _AppState();
}
class _AppState extends State<App> {
// A locking semaphore, it prevents unnecessary continuous updates of the
// bloc state when the user is not engaging with the app.
bool allowBlocStateUpdates = false;
allowBlocUpdates(bool allow) => setState(() => allowBlocStateUpdates = allow);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Listener(
// Only to prevent unnecessary state updates to the FlexibleHeader's bloc.
onPointerMove: (details) => allowBlocUpdates(true),
onPointerUp: (details) => allowBlocUpdates(false),
child: DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
// Custom widget responsible for the effect
FlexibleHeader(
allowBlocStateUpdates: allowBlocStateUpdates,
innerBoxIsScrolled: innerBoxIsScrolled,
),
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
new TabBar(
indicatorColor: Colors.white,
indicatorWeight: 3.0,
tabs: <Tab>[
Tab(text: 'TAB 1'),
Tab(text: 'TAB 2'),
],
),
),
),
];
},
body: TabBarView(
children: <Widget>[
SingleChildScrollView(
child: Container(
height: 300.0,
child: Text('Test 1',
style: TextStyle(color: Colors.black, fontSize: 80.0)),
),
),
SingleChildScrollView(
child: Container(
height: 300.0,
child: Text('Test 2',
style: TextStyle(color: Colors.red, fontSize: 80.0)),
),
),
],
),
),
),
),
);
}
}
// Not modified
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate(this._tabBar);
final TabBar _tabBar;
@override
double get minExtent => _tabBar.preferredSize.height;
@override
double get maxExtent => _tabBar.preferredSize.height;
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(
color: Colors.deepPurple,
child: _tabBar,
);
}
@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return false;
}
}
The second file flexible_header.dart contains the StreamBuilder and the LayoutBuilder, which closely interact with the bloc to update the UI with new opacity values. New height values are passed to the bloc which in turn updates the opacity.
import 'package:flutter/material.dart';
import 'bloc.dart'; // The code in the next listing
/// Creates a SliverAppBar that gradually toggles (with opacity) between
/// showing the widget in the flexible space, and the SliverAppBar's title and leading.
class FlexibleHeader extends StatefulWidget {
final bool allowBlocStateUpdates;
final bool innerBoxIsScrolled;
const FlexibleHeader(
{Key key, this.allowBlocStateUpdates, this.innerBoxIsScrolled})
: super(key: key);
@override
_FlexibleHeaderState createState() => _FlexibleHeaderState();
}
class _FlexibleHeaderState extends State<FlexibleHeader> {
FlexibleHeaderBloc bloc;
@override
void initState() {
super.initState();
bloc = FlexibleHeaderBloc();
}
@override
void dispose() {
super.dispose();
bloc.dispose();
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
initialData: bloc.initial(),
stream: bloc.stream,
builder: (BuildContext context, AsyncSnapshot<FlexibleHeaderState> stream) {
FlexibleHeaderState state = stream.data;
// Main widget responsible for the effect
return SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
child: SliverAppBar(
expandedHeight: 254,
pinned: true,
primary: true,
leading: Opacity(
opacity: state.opacityAppBar,
child: Icon(Icons.arrow_back),
),
title: Opacity(
opacity: state.opacityAppBar,
child: Text('Fade'),
),
forceElevated: widget.innerBoxIsScrolled,
flexibleSpace: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// LayoutBuilder allows us to receive the max height of
// the widget, the first value is stored in the bloc which
// allows later values to easily be compared to it.
//
// Simply put one can easily turn it to a double from 0-1 for
// opacity.
print("BoxConstraint - Max Height: ${constraints.maxHeight}");
if (widget.allowBlocStateUpdates) {
bloc.update(state, constraints.maxHeight);
}
return Opacity(
opacity: state.opacityFlexible,
child: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
centerTitle: true,
title: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
// Remove flexible for constant width of the
// CircleAvatar, but only if you want to introduce a
// RenderFlex overflow error for the text, but it is
// only visible when opacity is very low.
Flexible(
child: CircleAvatar(
radius: 36.0,
child: Text('N',
style: TextStyle(color: Colors.white)),
backgroundColor: Colors.green),
),
Flexible(child: Text('My Name')),
],
),
background: Container(color: Colors.purple),
),
);
},
)),
);
},
);
}
}
The third file is a bloc, bloc.dart. To obtain the opacity effect some math had to be done, and checking that the opacity value was between 0 to 1, the solution is not perfect, but it works.
import 'dart:async';
/// The variables necessary for proper functionality in the FlexibleHeader
class FlexibleHeaderState{
double initialHeight;
double currentHeight;
double opacityFlexible = 1;
double opacityAppBar = 0;
FlexibleHeaderState();
}
/// Used in a StreamBuilder to provide business logic with how the opacity is updated.
/// depending on changes to the height initially
/// available when flutter builds the widget the first time.
class FlexibleHeaderBloc{
StreamController<FlexibleHeaderState> controller = StreamController<FlexibleHeaderState>();
Sink get sink => controller.sink;
Stream<FlexibleHeaderState> get stream => controller.stream;
FlexibleHeaderBloc();
_updateOpacity(FlexibleHeaderState state) {
if (state.initialHeight == null || state.currentHeight == null){
state.opacityFlexible = 1;
state.opacityAppBar = 0;
} else {
double offset = (1 / 3) * state.initialHeight;
double opacity = (state.currentHeight - offset) / (state.initialHeight - offset);
//Lines below prevents exceptions
opacity <= 1 ? opacity = opacity : opacity = 1;
opacity >= 0 ? opacity = opacity : opacity = 0;
state.opacityFlexible = opacity;
state.opacityAppBar = (1-opacity).abs(); // Inverse the opacity
}
}
update(FlexibleHeaderState state, double currentHeight){
state.initialHeight ??= currentHeight;
state.currentHeight = currentHeight;
_updateOpacity(state);
_update(state);
}
FlexibleHeaderState initial(){
return FlexibleHeaderState();
}
void dispose(){
controller.close();
}
void _update(FlexibleHeaderState state){
sink.add(state);
}
}
Hope this helps somebody :)