Open20

Navigator 2.0 関連スクラップ

ntaoontaoo

RouteInformationParser と RouteInformationProvider は、nested routerで指定する必要はない。constructor parameterとしては存在するが、どちらか一方だけの指定ではassertion errorが発生する。root router で指定するだけ。

アプリ開発者がRouteInformationProviderの機能をoverrideすることはほぼ無いだろう。新しいplatformに対応するときくらいか?

RouterのAPIがわかりにくいのは、Interface segregation principle に違反しているのも一因だろう。Nested RouterとRoot Routerの classを分けるべきでは。

ntaoontaoo

ちゃんと検証していない技術的な知見を雑に投稿していく

ntaoontaoo

Router と Navigator は一組で存在すると決めてしまって良さそうだ。ひとつのRouterのRouterDelegateのwidget treeに、NestedRouterを挟まずに複数のNavigatorが存在することはありえるだろうか?デザインドキュメントからはそのような可能性があることは読み取れない。たとえそのような構成がありえても、切り捨ててしまって構わないほど稀な気がする。( Navigator 2.0 を包んだパッケージを構想中)

ntaoontaoo

Webアプリでブラウザーの戻るボタンとアプリ内の戻る矢印ボタンの動作が異なるとユーザーが混乱するかもしれない問題については、動作をどちらかに統一するより、単にアプリ内の戻るボタンに適切なテキストをつけてやれば良いのではないか。"<- xxに戻る" というように。

モバイルWebの場合はそのテキストを配置する空間がないだろうけど、モバイルの場合はWebアプリでなくネイティブアプリで提供すればいいだろう(やや暴言)

ntaoontaoo

アプリの利用者は、はたして、Webアプリ内の戻るボタンアイコンとWebブラウザーの戻るボタンを同一の機能とみなすものだろうか?

ntaoontaoo

TransitionDelegateの役割を理解した。しかし、Navigator 1.0の命令型APIであるpop()には対応しないという仕様はややこしい。pop()はBackButton widgetで使われてしまっているので、使わないようにすることは難しい。

ntaoontaoo

A router package on top of navigator 2.0.

  • API should look familiar.
  • Lightweight, thin package.
  • Extending Router, which internally provides a default implementation of RouterDelegate and RouteInformationParser.
  • Defines a stack of routes, where the topmost route is corresponding to a location.
  • Route guard (Validator).
  • For jumping to a different route, semantic link widget for providing declarative way. "nagivateTo" API for providing an imperative way.
  • Pop API should be supported, that is a simple implementation with that mixin.
  • Nested routing, and a rebuild is usually run locally in a nested router scope.
  • Can cooperate with nested raw Router, for corner cases (for instance?).
  • Type safety.
ntaoontaoo

同じURLのルーティングでも、スクリーンサイズによって route stackの積み方が異なってくるのでは。

ntaoontaoo

With pseudo code, a routing difinition could be,

route: '/' to: AppShell
    innerRoutes:
      route: '/' to: HomePage
      route: '/playlist?list=WL' to: WatchLaterPage stack: [Library]
      route: '/playlist?list=:listId' to: WatchListPage stack: [Library]
route: '/sign-in' to: SignIn stack: [AppShell]
route: '/some-path/some-path2' to: SomePage stack: [AppShell]
withPrefix: '/admin' validator: AdminValidator()
  '/' to: Admin
  '/some-path' to: AdminChild1 stack: [Admin]
ntaoontaoo

次はスクリーンサイズ等の実行時要素を反映してのdynamic configurationを仕様に盛り込むとどのようなAPIになるのかを考案

ntaoontaoo

package:flit_router, おおよそこのようなAPIになるはず。(実装中)

import 'package:flutter/material.dart';
import 'package:flit_router/flit_router.dart';

void main() {
  runApp(NestedRouterDemo());
}

class NestedRouterDemo extends StatefulWidget {
  
  _NestedRouterDemoState createState() => _NestedRouterDemoState();
}

class _NestedRouterDemoState extends State<NestedRouterDemo> {
  
  Widget build(BuildContext context) {
    return FlitRootRouter(
      routes: () {
        return FlitRoutes(
          list: [
            FlitRoute(
              '/',
              to: (uri) =>
                  MaterialPage(child: AppShell(uri.pathSegments.first)),
            ),
          ],
        );
      },
      builder: (context, delegate, parser) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: delegate,
          routeInformationParser: parser,
        );
      },
    );
  }
}

class AppShell extends StatefulWidget {
  final String _pathSegment;

  AppShell(this._pathSegment) : assert(_pathSegment != null);

  
  _AppShellState createState() => _AppShellState();
}

class _AppShellState extends State<AppShell> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FlitRouter(
        routes: () {
          final booksRoute = FlitRoute(
            '/books',
            to: (uri) => FadeAnimationPage(
              key: ValueKey('BooksListPage'),
              child: BooksListScreen(),
            ),
          );

          return FlitRoutes(
            list: [
              booksRoute,
              FlitRoute(
                '/book/:id',
                to: (uri) {
                  final bookId = uri.pathSegments[1];
                  return MaterialPage(
                    key: ValueKey(bookId),
                    child: BookDetailsScreen(bookId: bookId),
                  );
                },
              )..addStack(booksRoute),
              FlitRoute(
                '/settings',
                to: (uri) => FadeAnimationPage(
                  child: SettingsScreen(),
                  key: ValueKey('SettingsPage'),
                ),
              ),
            ],
            whenNoMatch: () {
              // return 404
            },
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(
              icon: Icon(Icons.settings), label: 'Settings'),
        ],
        currentIndex: _pathSegmentToIndex[widget._pathSegment],
        onTap: (index) {
          final String pathSegment = _pathSegmentToIndex.entries
              .firstWhere((entry) => entry.value == index)
              .key;
          FlitRouter.of(context).navigateTo(Uri(pathSegments: [pathSegment]));
        },
      ),
    );
  }

  final _pathSegmentToIndex = const <String, int>{
    'books': 0,
    'settings': 1,
  };
}

class BooksListScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // If it is a production app, books data should get asynchronously.
    final _books = books;
    return Scaffold(
      body: ListView(
        children: [
          for (var book in _books)
            ListTile(
              title: Text(book.title),
              subtitle: Text(book.author),
              onTap: () {
                FlitRouter.of(context)
                    .navigateTo(Uri(pathSegments: ['books', book.id]));
              },
            )
        ],
      ),
    );
  }
}

class BookDetailsScreen extends StatelessWidget {
  final String bookId;

  BookDetailsScreen({ this.bookId});

  
  Widget build(BuildContext context) {
    // If it is a production app, a book data should get asynchronously.
    final book = books.firstWhere((book) => book.id == bookId);
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            FlatButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text('Back'),
            ),
            ...[
              Text(book.title, style: Theme.of(context).textTheme.headline6),
              Text(book.author, style: Theme.of(context).textTheme.subtitle1),
            ],
          ],
        ),
      ),
    );
  }
}

class SettingsScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Settings screen'),
      ),
    );
  }
}

class FadeAnimationPage extends Page {
  final Widget child;

  FadeAnimationPage({Key key, this.child}) : super(key: key);

  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
      settings: this,
      pageBuilder: (context, animation, animation2) {
        var curveTween = CurveTween(curve: Curves.easeIn);
        return FadeTransition(
          opacity: animation.drive(curveTween),
          child: child,
        );
      },
    );
  }
}

final List<Book> books = [
  Book('a', 'Stranger in a Strange Land', 'Robert A. Heinlein'),
  Book('b', 'Foundation', 'Isaac Asimov'),
  Book('c', 'Fahrenheit 451', 'Ray Bradbury'),
];

class Book {
  final String id;
  final String title;
  final String author;

  Book(this.id, this.title, this.author);
}
ntaoontaoo

Route validator, path prefix, "Link" widget, type-safe route configuration を実装したら一区切り。

Route configuration を元に route を含む UI を更新する。Model -> Presenter (Controller, Widget, ...whatever) -> UI.

Route configuration は、典型的にはURLから生成する。App state からも生成 してもよい。navigateTo(aRouteConfiguration) が、routerDelegateへのnotifyの syntax sugar になるはず。

Route configuration は、まずは親しみやすい String ベース で始め、次に User defined type の仕様を考案する。

Router の rebuild を伴わない URL のリストア方法も提供する。

ntaoontaoo
  • モバイルネイティブアプリでは、restoreRouteInformationは必要ない
  • デスクトップWebアプリではモバイルネイティブアプリに見られるような、drag move ( touch move ) による複雑な操作の結果によるURL更新はほぼ行われないだろう。
  • デスクトップネイティブアプリは、Webアプリに準ずる。そしてRouteという概念すら無いことが多い
ntaoontaoo

最終?候補

abstract class RouteController {
  Matcher get matcher;
  Page get page;
  RouteInformation get routeInformation;
  SetNewRoutePathHandler get setNewRoutePathHandler;
  OnPopPage get onPopPage;
  RouteInformation get currentConfiguration => restoreRouteInformation();
  RouteInformation restoreRouteInformation();
}

class BookRouteController extends RouteController {}

FlitRouter(
  routes: () {
    return FlitRoutes(
      routes: [
        FlitRoute(
          '/books',
          to: (match) {
            return [
              BooksRouteController(match);
            ];
          }
        ),
        FlitRoute(
          '/book/:id', // BookRouteController.matcher
          to: (match) {
            final bookId = match.uri.pathSegments[1];
            return [
              BooksRouteController(match),
              BookRouteController(bookId, match),
            ];
          },
        ),
        FlitRoute(
          '/settings',
          to: (match) => SettingsRouteController(match),
        ),
      ],
      whenNoMatch: () {
        // return 404
      },
    );
  },
),
ntaoontaoo
navigateTo(BookPath(id: 1));
navigateTo<RoutePath>(BookPath(id: 1));
navigateToUri(BookPath(id: 1).toUri);
navigateTo<Uri>(BookPath(id: 1).toUri);
navigateToString('books/1'); // trie matcher.
navigateTo<String>('books/1'); // trie matcher.
ntaoontaoo

概ねAPIが完成したと思う。あとは実装の完成とサンプルコードの提供をして、そしてGitHub issue と Reddit で宣伝する。

ntaoontaoo
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flit_router/flit_router.dart';

void main() {
  runApp(BooksApp());
}

class BooksApp extends StatefulWidget {
  
  State<StatefulWidget> createState() => _BooksAppState();
}

class _BooksAppState extends State<BooksApp> {
  
  Widget build(BuildContext context) {
    return FlitRootRouter(
      key: ValueKey('root'),
      routes: FlitRoutes(
        builder: () {
          final booksListRouteController = (routePath) {
            return RouteController(
              page: MaterialPage(
                key: ValueKey('BooksListPage'),
                child: BooksListScreen(
                  books: books,
                ),
              ),
              routePath: routePath,
            );
          };

          return [
            FlitRoute(
              '/books',
              to: (match) {
                return SynchronousFuture(
                  [booksListRouteController(match.path)],
                );
              },
            ),
            FlitRoute(
              '/books/:id',
              to: (match) {
                return SynchronousFuture(
                  [
                    booksListRouteController(
                      RoutePath.fromRawLocation(location: '/books'),
                    ),
                    RouteController(
                      page: BookDetailsPage(
                          bookId: match.matchedParameters['id']!),
                      routePath: match.path,
                    )
                  ],
                );
              },
            ),
          ];
        },
      ),
      appBuilder: (context, delegate, parser) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: delegate,
          routeInformationParser: parser,
        );
      },
    );
  }
}

class BookDetailsPage extends Page {
  final String bookId;

  BookDetailsPage({required this.bookId}) : super(key: ValueKey(bookId));

  Route createRoute(BuildContext context) {
    return MaterialPageRoute(
      settings: this,
      builder: (BuildContext context) {
        return BookDetailsScreen(
            book: books.firstWhere((element) => element.id == bookId));
      },
    );
  }
}

class BooksListScreen extends StatelessWidget {
  final List<Book> books;

  BooksListScreen({required this.books});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: ListView(
        children: [
          for (var book in books)
            ListTile(
              title: Text(book.title),
              subtitle: Text(book.author),
              onTap: () {
                FlitRouter.of(context).navigateTo(
                    RoutePath(uri: Uri(pathSegments: ['books', book.id])));
              },
            )
        ],
      ),
    );
  }
}

class BookDetailsScreen extends StatelessWidget {
  final Book? book;

  BookDetailsScreen({required this.book});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (book != null) ...[
              Text(book!.title, style: Theme.of(context).textTheme.headline6),
              Text(book!.author, style: Theme.of(context).textTheme.subtitle1),
            ],
          ],
        ),
      ),
    );
  }
}

class UnknownScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Text('404!'),
      ),
    );
  }
}

bool show404 = false;

final List<Book> books = [
  Book('a', 'Stranger in a Strange Land', 'Robert A. Heinlein'),
  Book('b', 'Foundation', 'Isaac Asimov'),
  Book('c', 'Fahrenheit 451', 'Ray Bradbury'),
];

class Book {
  final String id;
  final String title;
  final String author;

  Book(this.id, this.title, this.author);
}

ntaoontaoo

Navigator 2.0の辛いところ、Router で、parseRouteInformation, setNewRoutePath, currentConfiguration, restoreRouteInformation の 4 箇所で同じような条件分岐が発生するのが辛い。Navigator の Pages, onPopPage でもさらに発生する。系 6 箇所。onPopRoute でも発生するかもしれない。そうすると 7 箇所。