Implement diff algo, still struggling with the list though

This commit is contained in:
Simon Binder 2019-02-20 16:37:30 +01:00
parent 9e1566186c
commit 11d6f5f9fe
16 changed files with 648 additions and 82 deletions

77
sally/lib/diff_util.dart Normal file
View File

@ -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<EditAction> diff<T>(List<T> a, List<T> b) {
final snakes = impl.calculateDiff(impl.DiffInput(a, b));
final actions = <EditAction>[];
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;
}

View File

@ -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<T> into<T>(TableInfo<dynamic, T> table) =>
InsertStatement<T>(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<Tbl, ReturnType> update<Tbl, ReturnType>(
TableInfo<Tbl, ReturnType> 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<Table, ReturnType> select<Table, ReturnType>(
TableInfo<Table, ReturnType> table) {
return SelectStatement<Table, ReturnType>(this, table);
}
/// Starts a [DeleteStatement] that can be used to delete rows from a table.
@protected @visibleForTesting
DeleteStatement<Table> delete<Table>(TableInfo<Table, dynamic> table) =>
DeleteStatement<Table>(this, table);
}

View File

@ -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.

View File

@ -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<T> {
final List<T> from;
final List<T> to;
DiffInput(this.from, this.to);
bool areItemsTheSame(int fromPos, int toPos) => from[fromPos] == to[toPos];
}
List<Snake> calculateDiff(DiffInput input) {
final oldSize = input.from.length;
final newSize = input.to.length;
final snakes = <Snake>[];
final stack = <Range>[];
stack.add(Range(0, oldSize, 0, newSize));
final max = oldSize + newSize + (oldSize - newSize).abs();
final forward = List<int>(max * 2);
final backward = List<int>(max * 2);
final rangePool = <Range>[];
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<int> forward, List<int> 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');
}

View File

@ -0,0 +1,27 @@
import 'package:test_api/test_api.dart';
import 'package:sally/diff_util.dart';
List<T> applyEditScript<T>(List<T> a, List<T> b, List<EditAction> 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);
});
}

View File

@ -1,19 +0,0 @@
import 'package:sally_example/database.dart';
class TodoBloc {
final Database _db = Database();
Stream<List<TodoEntry>> get todosForHomepage => _db.todosWithoutCategories;
void createTodoEntry(String text) {
_db.addTodoEntry(TodoEntry(
content: text,
));
}
void dispose() {
}
}

View File

@ -43,30 +43,11 @@ class Database extends _$Database {
}
);
Stream<List<Category>> get definedCategories => select(categories).watch();
Stream<List<TodoEntry>> todosInCategories(List<Category> categories) {
final ids = categories.map((c) => c.id);
return (select(todos)..where((t) => isIn(t.category, ids))).watch();
Stream<List<TodoEntry>> allEntries() {
return select(todos).watch();
}
Future<void> deleteOldEntries() {
return (delete(todos)..where((t) => year(t.targetDate).equals(2017))).go();
}
Stream<List<TodoEntry>> watchEntriesInCategory(Category c) {
return (select(todos)..where((t) => t.category.equals(c.id))).watch();
}
Stream<List<TodoEntry>> get todosWithoutCategories =>
(select(todos)..where((t) => isNull(t.category))).watch();
Future<List<TodoEntry>> sortEntriesAlphabetically() {
return (select(todos)..orderBy([(u) => OrderingTerm(expression: u.title)])).get();
}
Future addTodoEntry(TodoEntry entry) {
Future addEntry(TodoEntry entry) {
return into(todos).insert(entry);
}
}

View File

@ -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<MyApp> {
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<MyApp> {
}
}
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;
}

View File

@ -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<List<TodoEntry>>(
stream: bloc.todosForHomepage,
builder: (ctx, snapshot) {
final data = snapshot.hasData ? snapshot.data : <TodoEntry>[];
return SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, index) => Text(data[index].content),
childCount: data.length,
),
appBar: AppBar(title: Text('Todo list'),),
body: SallyAnimatedList<TodoEntry>(
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));
},
),
),
);

View File

@ -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),
);
}
}

View File

@ -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.

View File

@ -0,0 +1,123 @@
/*
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:sally_flutter/src/utils.dart';
typedef Widget AppearingAnimationBuilder<T>(
BuildContext ctx, T item, Animation<double> animation,
{int index});
typedef Widget OutgoingAnimationBuilder<T>(
BuildContext ctx, T item, Animation<double> animation,
{int index});
/// A list that animatse
class AnimatedStreamList<T> extends StatefulWidget {
static const Widget _defaultPlaceholder = SizedBox();
/// Builder that builds widget as they appear on the list.
final AppearingAnimationBuilder<T> appearing;
/// Builder that builds widgets as they leave the list.
final OutgoingAnimationBuilder<T> 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<T> createState() => _AnimatedStreamListState<T>();
}
const Duration _kDuration = Duration(milliseconds: 300);
class _AnimatedStreamListState<T> extends State<AnimatedStreamList<T>>
with TickerProviderStateMixin {
StreamSubscription _subscription;
List<T> _lastSnapshot;
final List<_AnimatedItemState> _insertingItems = [];
final List<_AnimatedItemState> _leavingItems = [];
void _handleDataReceived(List<T> 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);
}
*/

View File

@ -0,0 +1,118 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:sally/diff_util.dart';
typedef Widget ItemBuilder<T>(BuildContext context, T item, Animation<double> anim);
typedef Widget RemovedItemBuilder<T>(BuildContext context, T item, Animation<double> anim);
/// An [AnimatedList] that shows the result of a sally query stream.
class SallyAnimatedList<T> extends StatefulWidget {
final Stream<List<T>> stream;
final ItemBuilder<T> itemBuilder;
final RemovedItemBuilder<T> removedItemBuilder;
SallyAnimatedList({@required this.stream, @required this.itemBuilder, @required this.removedItemBuilder});
@override
_SallyAnimatedListState createState() {
return _SallyAnimatedListState();
}
}
class _SallyAnimatedListState<T> extends State<SallyAnimatedList<T>> {
List<T> _lastSnapshot;
int _initialItemCount;
StreamSubscription _subscription;
final GlobalKey<AnimatedListState> _key = GlobalKey();
AnimatedListState get listState => _key.currentState;
@override
void initState() {
_setupSubscription();
super.initState();
}
void _receiveData(List<T> 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<T> 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;
},
);
}
}

View File

@ -0,0 +1,3 @@
void insertIntoSortedList<T>(List<T> list, T entry, {int compare(T a, T b)}) {
}

View File

@ -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"

View File

@ -10,6 +10,7 @@ environment:
sdk: ">=2.0.0-dev.68.0 <3.0.0"
dependencies:
collection:
sally:
path: ../sally
sqflite: