Flutter Hook - Summary
Hooks ?
- let us organize the logic inside a component into reusable isolated units.
- let use functions instead of having to constantly switch between functions, classes, higher-order components, and render props.
- are fully encapsulated and work as a isolated local state within the currently running component.
- redeems statefulWidget's difficulty of logic reusability.
(Ref -> https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889)
Prerequisites : Add the dependency - "flutter_hooks"
Principles && Rules:
- use it only in the "build" method.
- the same hook can be reused arbitrarily many times.
- hooks are totally independent even from the widget.
- use it with prefix "use". (ex: useHook();)
- do not wrap "use" into a condition. (ex: if (flag) {useHook();})
(Tutorial Ref -> https://www.youtube.com/watch?v=XsbxM1Aztpo)
Most-Used Hooks
1. useState
-> creates a variable and subscribe to it and based in ValueNotifier.
2. useEffect
-> useful for side-effects and optionally canceling them.
3. useFuture
-> subscribes to a future and returns its current state as an AsyncSnapshot, but doesn't hold any state in memory.
@override
Widget build(BuildContext context) {
final image = useFuture(
NetworkAssetBundle(Uri.parse(url))
.load(url)
.then((data) => data.buffer.asUint8List())
.then((data) => Image.memory(data)),
);
return Column(
children: [
image.hasData
? image.data!
: null
].compactMap().toList(),
);
}
** Image will be flickering every time when it is loaded as useFuture doesn't hold any state of it.
4. useMemoized
-> caches the instance of a complex object.
extension CompactMap<T> on Iterable<T?> {
Iterable<T> compactMap<E>([
E? Function(T?)? transform,
]) =>
map(
transform ?? (e) => e,
).where((e) => e != null).cast();
}
Widget build(BuildContext context) {
final future = useMemoized(() {
NetworkAssetBundle(Uri.parse(url))
.load(url)
.then((data) => data.buffer.asUint8List())
.then((data) => Image.memory(data));
});
final snapshot = useFuture(future);
return Column(
children: <Widget>[snapshot.data].compactMap().toList(),
);
}
** Image will be downloaded only once and be cached further by useMemoised.
5. useListenable
-> subscribes to a Listenable rebuilds the widget once a new values is detected.
class CountDown extends ValueNotifier<int> {
late StreamSubscription sub;
CountDown({required int from}) : super(from) {
sub = Stream.periodic(const Duration(seconds: 1), (v) => from - v)
.takeWhile((e) => e >= 0)
.listen((event) {
value = event;
});
}
@override
void dispose() {
sub.cancel();
super.dispose();
}
}
class HomeScreen extends HookWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final counter = useMemoized(() => CountDown(from: 20));
final notifier = useListenable(counter);
return Text(notifier.value.toString());
}
** useListenable was attached to counter, as useMemoized caches the countdown, not renew the value.
5. useReducer
-> handles more complex states than useState
enum Action {
rotateLeft,
rotateRight,
moreVisible,
lessVisible,
}
@immutable
class State {
final double rotationDeg;
final double alpha;
const State({
required this.rotationDeg,
required this.alpha,
});
const State.zero()
: rotationDeg = 0.0,
alpha = 1.0;
// reducers
State rotateRight() => State(
alpha: alpha,
rotationDeg: rotationDeg + 10.0,
);
State rotateLeft() => State(
alpha: alpha,
rotationDeg: rotationDeg - 10.0,
);
State moreVisible() => State(
alpha: min(alpha + 0.1, 1.0),
rotationDeg: rotationDeg,
);
State lessVisible() => State(
alpha: max(alpha - 0.1, 0.0),
rotationDeg: rotationDeg,
);
}
State reducer(State oldState, Action? action) {
switch (action) {
case Action.rotateLeft:
return oldState.rotateLeft();
case Action.rotateRight:
return oldState.rotateRight();
case Action.moreVisible:
return oldState.moreVisible();
case Action.lessVisible:
return oldState.lessVisible();
case null:
return oldState;
}
}
class HomeScreen extends HookWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final store = useReducer<State, Action?>(
reducer,
initialState: const State.zero(),
initialAction: null,
);
return Scaffold(
appBar: AppBar(
title: const Text('HomePage'),
),
body: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: Action.values
.map((e) => TextButton(
onPressed: () {
store.dispatch(e);
},
child: Text(e.name),
))
.toList(),
),
const SizedBox(height: 50),
Opacity(
opacity: store.state.alpha,
child: RotationTransition(
turns: AlwaysStoppedAnimation(
store.state.rotationDeg / 360.0,
),
child: Image.network(url),
),
),
],
),
);
}
6. useAppLifecycleState
-> returns the current lifecycle state and rebuilds the widget once it gets changed. (!= useOnAppLifecycleStateChange -> listens to AppLifeCycleState's changes and triggers a callback on change)
final state = useAppLifecycleState();
return Scaffold(
appBar: AppBar(
title: const Text('HomePage'),
),
body: Opacity(
opacity: state == AppLifecycleState.resumed ? 1.0 : 0.0,
child: Container(
margin: const EdgeInsets.all(20),
Other Hooks
1. useTextEditingController();
Widget build(BuildContext context) {
final controller = useTextEditingController();
final text = useState('');
useEffect(() {
controller.addListener(() {
text.value = controller.text;
});
return null;
}, [controller]); // controller serves as a key
return Scaffold(
appBar: AppBar(
title: const Text('HomePage'),
),
body: Padding(
padding: const EdgeInsets.fromLTRB(20, 100, 20, 0),
child: Column(children: [
TextFormField(
controller: controller,
),
Text('You typed - ${text.value}'),
]),
),
);
}
2. useAnimationController() && useScrollController();
const url =
"https://thumbs.dreamstime.com/b/ozero-malaya-ritsa-abkhazia-early-evening-lake-small-108638026.jpg";
const imgHeight = 320.0;
extension Normalize on num {
num normalized(
num selfRangeMin,
num selfRangeMax, [
num normalizedRangeMin = 0.0,
num normalizedRangeMax = 1.0,
]) =>
(normalizedRangeMax - normalizedRangeMin) *
((this - selfRangeMin) / (selfRangeMax - selfRangeMin)) +
normalizedRangeMin;
}
class HomeScreen extends HookWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final opacity = useAnimationController(
duration: const Duration(seconds: 1),
initialValue: 1.0,
lowerBound: 0.0,
upperBound: 1.0,
);
final size = useAnimationController(
duration: const Duration(seconds: 1),
initialValue: 1.0,
lowerBound: 0.0,
upperBound: 1.0,
);
final controller = useScrollController();
useEffect(() {
controller.addListener(() {
final newOpacity = max(imgHeight - controller.offset, 0.0);
final normalized = newOpacity.normalized(0.0, imgHeight).toDouble();
opacity.value = normalized;
size.value = normalized;
});
return null;
}, [controller]);
return Scaffold(
appBar: AppBar(
title: const Text('HomePage'),
),
body: Column(
children: [
SizeTransition(
sizeFactor: size,
axis: Axis.vertical,
axisAlignment: -1.0,
child: FadeTransition(
opacity: opacity,
child: Image.network(
url,
height: imgHeight,
fit: BoxFit.cover,
),
),
),
Expanded(
child: ListView.builder(
controller: controller,
itemCount: 100,
itemBuilder: (ctx, idx) => ListTile(
title: Text('IDX - ${idx + 1}'),
),
),
)
],
),
);
}
3. useStreamController();
late final StreamController<double> controller;
controller = useStreamController<double>(onListen: () {
controller.sink.add(0.0);
});
return Scaffold(
appBar: AppBar(
title: const Text('HomePage'),
),
body: StreamBuilder<double>(
stream: controller.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
} else {
final rotation = snapshot.data ?? 0.0;
return GestureDetector(
onTap: () {
controller.sink.add(rotation + 10.0);
},
child: RotationTransition(
turns: AlwaysStoppedAnimation(rotation / 360.0),
child: Center(
child: Image.network(url),
),
),
);
}
},
),
);