From 11d6f5f9fe063fc926263feb63b4dc9306c77ae5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 20 Feb 2019 16:37:30 +0100 Subject: [PATCH] Implement diff algo, still struggling with the list though --- sally/lib/diff_util.dart | 77 ++++++ sally/lib/src/runtime/executor/executor.dart | 4 + .../src/runtime/executor/stream_queries.dart | 2 + .../lib/src/utils/android_diffutils_port.dart | 243 ++++++++++++++++++ sally/test/diff_util_test.dart | 27 ++ sally_flutter/example/lib/bloc.dart | 19 -- sally_flutter/example/lib/database.dart | 25 +- sally_flutter/example/lib/main.dart | 30 +-- .../example/lib/widgets/homescreen.dart | 39 ++- .../example/lib/widgets/todo_card.dart | 16 ++ sally_flutter/lib/sally_flutter.dart | 1 + sally_flutter/lib/src/animated_list.dart | 123 +++++++++ sally_flutter/lib/src/animated_list_old.dart | 118 +++++++++ sally_flutter/lib/src/utils.dart | 3 + sally_flutter/pubspec.lock | 2 +- sally_flutter/pubspec.yaml | 1 + 16 files changed, 648 insertions(+), 82 deletions(-) create mode 100644 sally/lib/diff_util.dart create mode 100644 sally/lib/src/utils/android_diffutils_port.dart create mode 100644 sally/test/diff_util_test.dart delete mode 100644 sally_flutter/example/lib/bloc.dart create mode 100644 sally_flutter/example/lib/widgets/todo_card.dart create mode 100644 sally_flutter/lib/src/animated_list.dart create mode 100644 sally_flutter/lib/src/animated_list_old.dart create mode 100644 sally_flutter/lib/src/utils.dart diff --git a/sally/lib/diff_util.dart b/sally/lib/diff_util.dart new file mode 100644 index 00000000..4eb6aeea --- /dev/null +++ b/sally/lib/diff_util.dart @@ -0,0 +1,77 @@ +/// A utility library to find an edit script that turns a list into another. +/// This is useful when displaying a updating stream of immutable lists in a +/// list that can be updated. +library diff_util; + +import 'package:sally/src/utils/android_diffutils_port.dart' as impl; + +class EditAction { + + /// The index of the first list on which this action should be applied. If + /// this action [isDelete], that index and the next [amount] indices should be + /// deleted. Otherwise, this index should be moved back by [amount] and + /// entries from the second list (starting at [indexFromOther]) should be + /// inserted into the gap. + final int index; + /// The amount of entries affected by this action + final int amount; + /// If this action [isInsert], this is the first index from the second list + /// from where the items should be taken from. + final int indexFromOther; + + /// Whether this action should delete entries from the first list + bool get isDelete => indexFromOther == null; + /// Whether this action should insert entries into the first list + bool get isInsert => indexFromOther != null; + + EditAction(this.index, this.amount, this.indexFromOther); + + @override + String toString() { + if (isDelete) { + return 'EditAction: Delete $amount entries from the first list, starting ' + 'at index $index'; + } else { + return 'EditAction: Insert $amount entries into the first list, taking ' + 'them from the second list starting at $indexFromOther. The entries ' + 'should be written starting at index $index'; + } + } + +} + +/// Finds the shortest edit script that turns list [a] into list [b]. +/// The implementation is ported from androids DiffUtil, which in turn +/// implements a variation of Eugene W. Myer's difference algorithm. The +/// algorithm is optimized for space and uses O(n) space to find the minimal +/// number of addition and removal operations between the two lists. It has +/// O(N + D^2) time performance, where D is the minimum amount of edits needed +/// to turn a into b. +List diff(List a, List b) { + final snakes = impl.calculateDiff(impl.DiffInput(a, b)); + final actions = []; + + var posOld = a.length; + var posNew = b.length; + for (var snake in snakes.reversed) { + final snakeSize = snake.size; + final endX = snake.x + snakeSize; + final endY = snake.y + snakeSize; + + if (endX < posOld) { + // starting (including) with index endX, delete posOld - endX chars from a + actions.add(EditAction(endX, posOld - endX, null)); + } + if (endY < posNew) { + // starting with index endX, insert posNex - endY characters into a. The + // characters should be taken from b, starting (including) at the index + // endY. The char that was at index endX should be pushed back. + actions.add(EditAction(endX, posNew - endY, endY)); + } + + posOld = snake.x; + posNew = snake.y; + } + + return actions; +} diff --git a/sally/lib/src/runtime/executor/executor.dart b/sally/lib/src/runtime/executor/executor.dart index 49dcb87f..f71d6aa6 100644 --- a/sally/lib/src/runtime/executor/executor.dart +++ b/sally/lib/src/runtime/executor/executor.dart @@ -76,12 +76,14 @@ abstract class GeneratedDatabase { /// Starts an [InsertStatement] for a given table. You can use that statement /// to write data into the [table] by using [InsertStatement.insert]. + @protected @visibleForTesting InsertStatement into(TableInfo table) => InsertStatement(this, table); /// Starts an [UpdateStatement] for the given table. You can use that /// statement to update individual rows in that table by setting a where /// clause on that table and then use [UpdateStatement.write]. + @protected @visibleForTesting UpdateStatement update( TableInfo table) => UpdateStatement(this, table); @@ -89,12 +91,14 @@ abstract class GeneratedDatabase { /// Starts a query on the given table. Queries can be limited with an limit /// or a where clause and can either return a current snapshot or a continuous /// stream of data + @protected @visibleForTesting SelectStatement select( TableInfo table) { return SelectStatement(this, table); } /// Starts a [DeleteStatement] that can be used to delete rows from a table. + @protected @visibleForTesting DeleteStatement delete
(TableInfo table) => DeleteStatement
(this, table); } diff --git a/sally/lib/src/runtime/executor/stream_queries.dart b/sally/lib/src/runtime/executor/stream_queries.dart index 646de9e6..92fef7f6 100644 --- a/sally/lib/src/runtime/executor/stream_queries.dart +++ b/sally/lib/src/runtime/executor/stream_queries.dart @@ -7,6 +7,8 @@ import 'package:sally/sally.dart'; class StreamQueryStore { final List<_QueryStream> _activeStreams = []; + // todo cache streams (return same instance for same sql + variables) + StreamQueryStore(); /// Creates a new stream from the select statement. diff --git a/sally/lib/src/utils/android_diffutils_port.dart b/sally/lib/src/utils/android_diffutils_port.dart new file mode 100644 index 00000000..5c2aa80d --- /dev/null +++ b/sally/lib/src/utils/android_diffutils_port.dart @@ -0,0 +1,243 @@ +// ignore_for_file: cascade_invocations + +/* +This implementation is copied from the DiffUtil class of the android support +library, available at https://chromium.googlesource.com/android_tools/+/refs/heads/master/sdk/sources/android-25/android/support/v7/util/DiffUtil.java +It has the following license: + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class Snake { + int x; + int y; + int size; + bool removal; + bool reverse; +} + +class Range { + int oldListStart, oldListEnd; + int newListStart, newListEnd; + + Range.nullFields(); + Range(this.oldListStart, this.oldListEnd, this.newListStart, this.newListEnd); +} + +class DiffInput { + final List from; + final List to; + + DiffInput(this.from, this.to); + + bool areItemsTheSame(int fromPos, int toPos) => from[fromPos] == to[toPos]; +} + +List calculateDiff(DiffInput input) { + final oldSize = input.from.length; + final newSize = input.to.length; + + final snakes = []; + final stack = []; + + stack.add(Range(0, oldSize, 0, newSize)); + + final max = oldSize + newSize + (oldSize - newSize).abs(); + + final forward = List(max * 2); + final backward = List(max * 2); + + final rangePool = []; + + while (stack.isNotEmpty) { + final range = stack.removeLast(); + final snake = _diffPartial(input, range.oldListStart, range.oldListEnd, + range.newListStart, range.newListEnd, forward, backward, max); + + if (snake != null) { + if (snake.size > 0) { + snakes.add(snake); + } + + // offset the snake to convert its coordinates from the Range's are to + // global + snake.x += range.oldListStart; + snake.y += range.newListStart; + + // add new ranges for left and right + final left = + rangePool.isEmpty ? Range.nullFields() : rangePool.removeLast(); + left.oldListStart = range.oldListStart; + left.newListStart = range.newListStart; + if (snake.reverse) { + left.oldListEnd = snake.x; + left.newListEnd = snake.y; + } else { + if (snake.removal) { + left.oldListEnd = snake.x - 1; + left.newListEnd = snake.y; + } else { + left.oldListEnd = snake.x; + left.newListEnd = snake.y - 1; + } + } + stack.add(left); + + final right = range; + if (snake.reverse) { + if (snake.removal) { + right.oldListStart = snake.x + snake.size + 1; + right.newListStart = snake.y + snake.size; + } else { + right.oldListStart = snake.x + snake.size; + right.newListStart = snake.y + snake.size + 1; + } + } else { + right.oldListStart = snake.x + snake.size; + right.newListStart = snake.y + snake.size; + } + stack.add(right); + } else { + rangePool.add(range); + } + } + + snakes.sort((a, b) { + final cmpX = a.x - b.x; + return cmpX == 0 ? a.y - b.y : cmpX; + }); + + // add root snake + final first = snakes.isEmpty ? null : snakes.first; + + if (first == null || first.x != 0 || first.y != 0) { + snakes.insert(0, Snake() + ..x = 0 + ..y = 0 + ..removal = false + ..size = 0 + ..reverse = false); + } + + return snakes; +} + +Snake _diffPartial(DiffInput input, int startOld, int endOld, int startNew, + int endNew, List forward, List backward, int kOffset) { + final oldSize = endOld - startOld; + final newSize = endNew - startNew; + + if (endOld - startOld < 1 || endNew - startNew < 1) return null; + + final delta = oldSize - newSize; + final dLimit = (oldSize + newSize + 1) ~/ 2; + + forward.fillRange(kOffset - dLimit - 1, kOffset + dLimit + 1, 0); + backward.fillRange( + kOffset - dLimit - 1 + delta, kOffset + dLimit + 1 + delta, oldSize); + + final checkInFwd = delta.isOdd; + + for (var d = 0; d <= dLimit; d++) { + for (var k = -d; k <= d; k += 2) { + // find forward path + // we can reach k from k - 1 or k + 1. Check which one is further in the + // graph. + int x; + bool removal; + + if (k == -d || + k != d && forward[kOffset + k - 1] < forward[kOffset + k + 1]) { + x = forward[kOffset + k + 1]; + removal = false; + } else { + x = forward[kOffset + k - 1] + 1; + removal = true; + } + + // set y based on x + var y = x - k; + + // move diagonal as long as items match + while (x < oldSize && + y < newSize && + input.areItemsTheSame(startOld + x, startNew + y)) { + x++; + y++; + } + + forward[kOffset + k] = x; + + if (checkInFwd && k >= delta - d + 1 && k <= delta + d - 1) { + if (forward[kOffset + k] >= backward[kOffset + k]) { + final outSnake = Snake()..x = backward[kOffset + k]; + outSnake + ..y = outSnake.x - k + ..size = forward[kOffset + k] - backward[kOffset + k] + ..removal = removal + ..reverse = false; + + return outSnake; + } + } + } + + for (var k = -d; k <= d; k += 2) { + // find reverse path at k + delta, in reverse + final backwardK = k + delta; + int x; + bool removal; + + if (backwardK == d + delta || + backwardK != -d + delta && + backward[kOffset + backwardK - 1] < + backward[kOffset + backwardK + 1]) { + x = backward[kOffset + backwardK - 1]; + removal = false; + } else { + x = backward[kOffset + backwardK + 1] - 1; + removal = true; + } + + // set y based on x + var y = x - backwardK; + // move diagonal as long as items match + while (x > 0 && + y > 0 && + input.areItemsTheSame(startOld + x - 1, startNew + y - 1)) { + x--; + y--; + } + + backward[kOffset + backwardK] = x; + + if (!checkInFwd && k + delta >= -d && k + delta <= d) { + if (forward[kOffset + backwardK] >= backward[kOffset + backwardK]) { + final outSnake = Snake()..x = backward[kOffset + backwardK]; + outSnake + ..y = outSnake.x - backwardK + ..size = + forward[kOffset + backwardK] - backward[kOffset + backwardK] + ..removal = removal + ..reverse = true; + + return outSnake; + } + } + } + } + + throw StateError("Unexpected case: Please make sure the lists don't change " + 'during a diff'); +} diff --git a/sally/test/diff_util_test.dart b/sally/test/diff_util_test.dart new file mode 100644 index 00000000..89faf0d2 --- /dev/null +++ b/sally/test/diff_util_test.dart @@ -0,0 +1,27 @@ +import 'package:test_api/test_api.dart'; +import 'package:sally/diff_util.dart'; + +List applyEditScript(List a, List b, List actions) { + final copy = List.of(a); + + for (var action in actions) { + if (action.isDelete) { + final deleteStartIndex = action.index; + copy.removeRange(deleteStartIndex, deleteStartIndex + action.amount); + } else { + final toAdd = b.getRange(action.indexFromOther, action.indexFromOther + action.amount); + copy.insertAll(action.index, toAdd); + } + } + + return copy; +} + +void main() { + final a = ['a', 'b', 'c', 'a', 'b', 'b', 'a']; + final b = ['c', 'b', 'a', 'b', 'a', 'c']; + + test('diff matcher should produce a correct edit script', () { + expect(applyEditScript(a, b, diff(a, b)), b); + }); +} \ No newline at end of file diff --git a/sally_flutter/example/lib/bloc.dart b/sally_flutter/example/lib/bloc.dart deleted file mode 100644 index 98b5093d..00000000 --- a/sally_flutter/example/lib/bloc.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:sally_example/database.dart'; - -class TodoBloc { - - final Database _db = Database(); - - Stream> get todosForHomepage => _db.todosWithoutCategories; - - void createTodoEntry(String text) { - _db.addTodoEntry(TodoEntry( - content: text, - )); - } - - void dispose() { - - } - -} \ No newline at end of file diff --git a/sally_flutter/example/lib/database.dart b/sally_flutter/example/lib/database.dart index 7066ba0a..23ff0978 100644 --- a/sally_flutter/example/lib/database.dart +++ b/sally_flutter/example/lib/database.dart @@ -43,30 +43,11 @@ class Database extends _$Database { } ); - Stream> get definedCategories => select(categories).watch(); - - Stream> todosInCategories(List categories) { - final ids = categories.map((c) => c.id); - - return (select(todos)..where((t) => isIn(t.category, ids))).watch(); + Stream> allEntries() { + return select(todos).watch(); } - Future deleteOldEntries() { - return (delete(todos)..where((t) => year(t.targetDate).equals(2017))).go(); - } - - Stream> watchEntriesInCategory(Category c) { - return (select(todos)..where((t) => t.category.equals(c.id))).watch(); - } - - Stream> get todosWithoutCategories => - (select(todos)..where((t) => isNull(t.category))).watch(); - - Future> sortEntriesAlphabetically() { - return (select(todos)..orderBy([(u) => OrderingTerm(expression: u.title)])).get(); - } - - Future addTodoEntry(TodoEntry entry) { + Future addEntry(TodoEntry entry) { return into(todos).insert(entry); } } diff --git a/sally_flutter/example/lib/main.dart b/sally_flutter/example/lib/main.dart index 8714cc25..a918c81c 100644 --- a/sally_flutter/example/lib/main.dart +++ b/sally_flutter/example/lib/main.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:sally_example/bloc.dart'; -import 'package:sally_example/widgets/homescreen.dart'; +import 'database.dart'; +import 'widgets/homescreen.dart'; void main() => runApp(MyApp()); @@ -13,24 +13,18 @@ class MyApp extends StatefulWidget { class MyAppState extends State { - TodoBloc _bloc; + Database _db; @override void initState() { - _bloc = TodoBloc(); + _db = Database(); super.initState(); } - @override - void dispose() { - _bloc.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return BlocProvider( - bloc: _bloc, + return DatabaseProvider( + db: _db, child: MaterialApp( title: 'Flutter Demo', theme: ThemeData( @@ -42,16 +36,16 @@ class MyAppState extends State { } } -class BlocProvider extends InheritedWidget { +class DatabaseProvider extends InheritedWidget { - final TodoBloc bloc; + final Database db; - BlocProvider({@required this.bloc, Widget child}) : super(child: child); + DatabaseProvider({@required this.db, Widget child}) : super(child: child); @override - bool updateShouldNotify(BlocProvider oldWidget) { - return oldWidget.bloc != bloc; + bool updateShouldNotify(DatabaseProvider oldWidget) { + return oldWidget.db != db; } - static TodoBloc provideBloc(BuildContext ctx) => (ctx.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider).bloc; + static Database provideDb(BuildContext ctx) => (ctx.inheritFromWidgetOfExactType(DatabaseProvider) as DatabaseProvider).db; } \ No newline at end of file diff --git a/sally_flutter/example/lib/widgets/homescreen.dart b/sally_flutter/example/lib/widgets/homescreen.dart index 7915a622..0156c125 100644 --- a/sally_flutter/example/lib/widgets/homescreen.dart +++ b/sally_flutter/example/lib/widgets/homescreen.dart @@ -1,40 +1,35 @@ import 'package:flutter/material.dart'; import 'package:sally_example/database.dart'; import 'package:sally_example/main.dart'; +import 'package:sally_example/widgets/todo_card.dart'; +import 'package:sally_flutter/sally_flutter.dart'; // ignore_for_file: prefer_const_constructors class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final bloc = BlocProvider.provideBloc(context); + final db = DatabaseProvider.provideDb(context); return Scaffold( - drawer: Text('Hi'), - body: CustomScrollView( - slivers: [ - SliverAppBar( - title: Text('TODO List'), - ), - StreamBuilder>( - stream: bloc.todosForHomepage, - builder: (ctx, snapshot) { - final data = snapshot.hasData ? snapshot.data : []; - - return SliverList( - delegate: SliverChildBuilderDelegate( - (ctx, index) => Text(data[index].content), - childCount: data.length, - ), - ); - }, - ), - ], + appBar: AppBar(title: Text('Todo list'),), + body: SallyAnimatedList( + stream: db.allEntries(), + itemBuilder: (ctx, TodoEntry item, animation) { + return SizeTransition( + sizeFactor: animation, + axis: Axis.vertical, + child: TodoCard(item), + ); + }, + removedItemBuilder: (_, __, ___) => Container(), ), bottomSheet: Material( elevation: 12.0, child: TextField( - onSubmitted: bloc.createTodoEntry, + onSubmitted: (content) { + db.addEntry(TodoEntry(content: content)); + }, ), ), ); diff --git a/sally_flutter/example/lib/widgets/todo_card.dart b/sally_flutter/example/lib/widgets/todo_card.dart new file mode 100644 index 00000000..7913a470 --- /dev/null +++ b/sally_flutter/example/lib/widgets/todo_card.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:sally_example/database.dart'; + +class TodoCard extends StatelessWidget { + + final TodoEntry entry; + + TodoCard(this.entry) : super(key: ObjectKey(entry.id)); + + @override + Widget build(BuildContext context) { + return Card( + child: Text(entry.content), + ); + } +} diff --git a/sally_flutter/lib/sally_flutter.dart b/sally_flutter/lib/sally_flutter.dart index 3400d266..1e9129fe 100644 --- a/sally_flutter/lib/sally_flutter.dart +++ b/sally_flutter/lib/sally_flutter.dart @@ -9,6 +9,7 @@ import 'package:path/path.dart'; import 'package:sally/sally.dart'; import 'package:sqflite/sqflite.dart'; +export 'package:sally_flutter/src/animated_list_old.dart'; export 'package:sally/sally.dart'; /// A query executor that uses sqlfite internally. diff --git a/sally_flutter/lib/src/animated_list.dart b/sally_flutter/lib/src/animated_list.dart new file mode 100644 index 00000000..13332d1d --- /dev/null +++ b/sally_flutter/lib/src/animated_list.dart @@ -0,0 +1,123 @@ +/* +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sally_flutter/src/utils.dart'; + +typedef Widget AppearingAnimationBuilder( + BuildContext ctx, T item, Animation animation, + {int index}); +typedef Widget OutgoingAnimationBuilder( + BuildContext ctx, T item, Animation animation, + {int index}); + +/// A list that animatse +class AnimatedStreamList extends StatefulWidget { + static const Widget _defaultPlaceholder = SizedBox(); + + /// Builder that builds widget as they appear on the list. + final AppearingAnimationBuilder appearing; + + /// Builder that builds widgets as they leave the list. + final OutgoingAnimationBuilder leaving; + + /// Widget that will be built when the stream emits an empty list after all + /// remaining list items have animated away. + final WidgetBuilder empty; + + /// Widget that will be built when the stream has not yet emitted any item. + final WidgetBuilder loading; + + AnimatedStreamList( + {@required this.appearing, + @required this.leaving, + this.empty, + this.loading}); + + @override + _AnimatedStreamListState createState() => _AnimatedStreamListState(); +} + +const Duration _kDuration = Duration(milliseconds: 300); + +class _AnimatedStreamListState extends State> + with TickerProviderStateMixin { + StreamSubscription _subscription; + + List _lastSnapshot; + final List<_AnimatedItemState> _insertingItems = []; + final List<_AnimatedItemState> _leavingItems = []; + + void _handleDataReceived(List data) { + if (_lastSnapshot == null) { + for (var i = 0; i < data.length; i++) { + _animateIncomingItem(i); + } + } else { + + } + + setState(() { + _lastSnapshot = data; + }); + } + + void _animateIncomingItem(int index) { + final controller = AnimationController(vsync: this); + final state = _AnimatedItemState(controller, true, index); + + insertIntoSortedList<_AnimatedItemState>(_insertingItems, state, + compare: (a, b) => a.itemIndex.compareTo(b.itemIndex)); + } + + void _animateOutgoingItem(int index) { + + } + + @override + void initState() {} + + @override + void didUpdateWidget(AnimatedStreamList oldWidget) {} + + @override + void dispose() { + for (var item in _insertingItems) { + item._controller.dispose(); + } + for (var item in _leavingItems) { + item._controller.dispose(); + } + + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_lastSnapshot == null) { + // no data yet, show placeholder + return widget.loading != null + ? widget.loading(context) + : AnimatedStreamList._defaultPlaceholder; + } else if (_lastSnapshot.isEmpty && _leavingItems.isEmpty) { + return widget.empty != null + ? widget.empty(context) + : AnimatedStreamList._defaultPlaceholder; + } + + return ListView.builder( + itemBuilder: (ctx, i) {}, + itemCount: _lastSnapshot.length + _leavingItems.length, + ); + } +} + +class _AnimatedItemState { + final AnimationController _controller; + final bool isInserting; + int itemIndex; + + _AnimatedItemState(this._controller, this.isInserting, this.itemIndex); +} +*/ diff --git a/sally_flutter/lib/src/animated_list_old.dart b/sally_flutter/lib/src/animated_list_old.dart new file mode 100644 index 00000000..1d2516f0 --- /dev/null +++ b/sally_flutter/lib/src/animated_list_old.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:sally/diff_util.dart'; + +typedef Widget ItemBuilder(BuildContext context, T item, Animation anim); +typedef Widget RemovedItemBuilder(BuildContext context, T item, Animation anim); + +/// An [AnimatedList] that shows the result of a sally query stream. +class SallyAnimatedList extends StatefulWidget { + + final Stream> stream; + final ItemBuilder itemBuilder; + final RemovedItemBuilder removedItemBuilder; + + SallyAnimatedList({@required this.stream, @required this.itemBuilder, @required this.removedItemBuilder}); + + @override + _SallyAnimatedListState createState() { + return _SallyAnimatedListState(); + } +} + +class _SallyAnimatedListState extends State> { + + List _lastSnapshot; + int _initialItemCount; + + StreamSubscription _subscription; + + final GlobalKey _key = GlobalKey(); + AnimatedListState get listState => _key.currentState; + + @override + void initState() { + _setupSubscription(); + super.initState(); + } + + void _receiveData(List data) { + if (listState == null) { + setState(() { + _lastSnapshot = data; + _initialItemCount = data.length; + }); + return; + } + + if (_lastSnapshot == null) { + // no diff possible. Initialize lists instead of diffing + _lastSnapshot = data; + for (var i = 0; i < data.length; i++) { + listState.insertItem(i); + } + } else { + final editScript = diff(_lastSnapshot, data); + + for (var action in editScript) { + if (action.isDelete) { + // we need to delete action.amount items at index action.index + for (var i = 0; i < action.amount; i++) { + // i items have already been deleted, so + 1 for the index. Notice + // that we don't have to do this when calling removeItem on the + // animated list state, as it will reflect the operation immediately. + final itemHere = _lastSnapshot[action.index + i]; + listState.removeItem(action.index, (ctx, anim) { + return widget.removedItemBuilder(ctx, itemHere, anim); + }); + } + } else { + for (var i = 0; i < action.amount; i++) { + listState.insertItem(action.index + i); + } + } + } + + setState(() { + _lastSnapshot = data; + }); + } + } + + void _setupSubscription() { + _subscription = widget.stream.listen(_receiveData); + } + + @override + void didUpdateWidget(SallyAnimatedList oldWidget) { + _subscription?.cancel(); + _lastSnapshot = null; + _setupSubscription(); + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_lastSnapshot == null) + return const SizedBox(); + + return AnimatedList( + key: _key, + initialItemCount: _initialItemCount ?? 0, + itemBuilder: (ctx, index, anim) { + final item = _lastSnapshot[index]; + final child = widget.itemBuilder(ctx, item, anim); + + return child; + }, + ); + } +} \ No newline at end of file diff --git a/sally_flutter/lib/src/utils.dart b/sally_flutter/lib/src/utils.dart new file mode 100644 index 00000000..f0022823 --- /dev/null +++ b/sally_flutter/lib/src/utils.dart @@ -0,0 +1,3 @@ +void insertIntoSortedList(List list, T entry, {int compare(T a, T b)}) { + +} \ No newline at end of file diff --git a/sally_flutter/pubspec.lock b/sally_flutter/pubspec.lock index 477427c8..cd09f05b 100644 --- a/sally_flutter/pubspec.lock +++ b/sally_flutter/pubspec.lock @@ -23,7 +23,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection url: "https://pub.dartlang.org" diff --git a/sally_flutter/pubspec.yaml b/sally_flutter/pubspec.yaml index 771c21df..f170d2ad 100644 --- a/sally_flutter/pubspec.yaml +++ b/sally_flutter/pubspec.yaml @@ -10,6 +10,7 @@ environment: sdk: ">=2.0.0-dev.68.0 <3.0.0" dependencies: + collection: sally: path: ../sally sqflite: