diff --git a/Mainpage.dox b/Mainpage.dox --- a/Mainpage.dox +++ b/Mainpage.dox @@ -23,6 +23,8 @@ - \link org::kde::kirigami::Page Page \endlink - \link org::kde::kirigami::ScrollablePage ScrollablePage \endlink - \link org::kde::kirigami::OverlaySheet OverlaySheet \endlink +- \link PageRouter PageRouter \endlink +- \link PageRoute PageRoute \endlink - \link org::kde::kirigami::Theme Theme \endlink - \link org::kde::kirigami::Units Units \endlink - \link org::kde::kirigami::Icon Icon \endlink diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -28,6 +28,7 @@ tst_pagerow.qml tst_icon.qml tst_actiontoolbar.qml + tst_pagerouter.qml pagepool/tst_pagepool.qml ) diff --git a/autotests/tst_pagerouter.qml b/autotests/tst_pagerouter.qml new file mode 100644 --- /dev/null +++ b/autotests/tst_pagerouter.qml @@ -0,0 +1,110 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 as QQC2 +import org.kde.kirigami 2.12 as Kirigami +import QtTest 1.0 + +Kirigami.PageRow { + id: root + TestCase { + name: "PageRouterGeneralTests" + function test_10_init() { + compare(router.currentRoutes().length, 1) + } + function test_20_navigate() { + router.navigateToRoute(["home", "login"]) + compare(router.currentRoutes().length, 2) + } + function test_30_data() { + router.navigateToRoute(["home", {"route": "login", "data": "red"}]) + compare(router.routeActive(["home", {"route": "login", "data": "red"}]), true) + compare(router.routeActive(["home", {"route": "login", "data": "blue"}]), false) + } + function test_40_cache_works() { + router.navigateToRoute(["home", {"route": "login", "data": "red"}, {"route": "login", "data": "blue"}]) + compare(router.currentRoutes().length, 3) + } + function test_50_push() { + router.pushRoute("home") + compare(router.currentRoutes().length, 4) + } + function test_60_pop() { + router.popRoute() + compare(router.currentRoutes().length, 3) + } + function test_70_bring_to_view() { + router.bringToView("home") + compare(root.columnView.currentIndex, 0) + router.bringToView({"route": "login", "data": "red"}) + compare(root.columnView.currentIndex, 1) + router.bringToView({"route": "login", "data": "blue"}) + compare(root.columnView.currentIndex, 2) + } + function test_80_routeactive() { + compare(router.routeActive(["home"]), true) + compare(router.routeActive(["home", "login"]), true) + compare(router.routeActive(["home", {"route": "login", "data": "red"}]), true) + compare(router.routeActive(["home", {"route": "login", "data": "blue"}]), false) + } + function test_90_initial_route() { + router.initialRoute = "login" + compare(router.routeActive(["login"]), false) + compare(router.currentRoutes().length, 3) + } + function test_100_navigation_two() { + router.navigateToRoute(["home", {"route": "login", "data": "red"}, {"route": "login", "data": "blue"}]) + compare(router.currentRoutes().length, 3) + router.navigateToRoute(["home"]) + compare(router.currentRoutes().length, 1) + compare(router.pageStack.count, 1) + } + } + Kirigami.PageRouter { + id: router + initialRoute: "home" + pageStack: root.columnView + + Kirigami.PageRoute { + name: "home" + cache: false + Component { + Kirigami.Page { + Column { + Kirigami.Heading { + text: "Welcome" + } + QQC2.Button { + text: "Red Login" + onClicked: Kirigami.PageRouter.navigateToRoute(["home", {"route": "login", "data": "red"}]) + } + QQC2.Button { + text: "Blue Login" + onClicked: Kirigami.PageRouter.navigateToRoute(["home", {"route": "login", "data": "blue"}]) + } + } + } + } + } + Kirigami.PageRoute { + name: "login" + cache: true + Component { + Kirigami.Page { + Column { + Kirigami.Heading { + text: "Login" + } + Rectangle { + height: 50 + width: 50 + color: Kirigami.PageRouter.data + } + QQC2.Button { + text: "Back to Home" + onClicked: Kirigami.PageRouter.navigateToRoute("home") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/pics/PageRouterModel.svg b/docs/pics/PageRouterModel.svg new file mode 100644 --- /dev/null +++ b/docs/pics/PageRouterModel.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/pics/PageRouterNavigate.svg b/docs/pics/PageRouterNavigate.svg new file mode 100644 --- /dev/null +++ b/docs/pics/PageRouterNavigate.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/pics/PageRouterPop.svg b/docs/pics/PageRouterPop.svg new file mode 100644 --- /dev/null +++ b/docs/pics/PageRouterPop.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/pics/PageRouterPush.svg b/docs/pics/PageRouterPush.svg new file mode 100644 --- /dev/null +++ b/docs/pics/PageRouterPush.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/PageRoute.qml b/examples/PageRoute.qml new file mode 100644 --- /dev/null +++ b/examples/PageRoute.qml @@ -0,0 +1,8 @@ +Kirigami.PageRoute { + name: "routeOne" + Component { + Kirigami.Page { + // Page contents... + } + } +} \ No newline at end of file diff --git a/examples/PageRouter.qml b/examples/PageRouter.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouter.qml @@ -0,0 +1,55 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 as QQC2 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + initialRoute: "home" + pageStack: applicaionWindow.pageStack.columnView + + Kirigami.PageRoute { + name: "home" + cache: false + Component { + Kirigami.Page { + Column { + Kirigami.Heading { + text: "Welcome" + } + QQC2.Button { + text: "Red Login" + onClicked: Kirigami.PageRouter.navigateToRoute(["home", {"route": "login", "data": "red"}]) + } + QQC2.Button { + text: "Blue Login" + onClicked: Kirigami.PageRouter.navigateToRoute(["home", {"route": "login", "data": "blue"}]) + } + } + } + } + } + Kirigami.PageRoute { + name: "login" + cache: true + Component { + Kirigami.Page { + Column { + Kirigami.Heading { + text: "Login" + } + Rectangle { + height: 50 + width: 50 + color: Kirigami.PageRouter.data + } + QQC2.Button { + text: "Back to Home" + onClicked: Kirigami.PageRouter.navigateToRoute("home") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/examples/PageRouterCachePagesDo.qml b/examples/PageRouterCachePagesDo.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouterCachePagesDo.qml @@ -0,0 +1,23 @@ +import QtQuick 2.12 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + initialRoute: "home" + pageStack: applicaionWindow.pageStack.columnView + + // home can be pushed onto the route twice + Component.onCompleted: navigateToRoute("home", "home") + + Kirigami.PageRoute { + name: "home" + cache: false + Component { + Kirigami.Page { + // Page contents... + } + } + } + } +} \ No newline at end of file diff --git a/examples/PageRouterCachePagesDont.qml b/examples/PageRouterCachePagesDont.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouterCachePagesDont.qml @@ -0,0 +1,23 @@ +import QtQuick 2.12 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + initialRoute: "home" + pageStack: applicaionWindow.pageStack.columnView + + // home can't be pushed onto the route twice + Component.onCompleted: navigateToRoute("home", "home") + + Kirigami.PageRoute { + name: "home" + cache: true + Component { + Kirigami.Page { + // Page contents... + } + } + } + } +} \ No newline at end of file diff --git a/examples/PageRouterColumnView.qml b/examples/PageRouterColumnView.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouterColumnView.qml @@ -0,0 +1,20 @@ +import QtQuick 2.12 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + pageStack: applicaionWindow.pageStack.columnView + + Kirigami.PageRoute { + name: "home" + Component { + Kirigami.Page { + // Page contents... + } + } + } + initialRoute: "home" + Component.onCompleted: navigateToRoute("home", "home") + } +} \ No newline at end of file diff --git a/examples/PageRouterInitialRoute.qml b/examples/PageRouterInitialRoute.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouterInitialRoute.qml @@ -0,0 +1,27 @@ +import QtQuick 2.12 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + initialRoute: "home" + pageStack: applicaionWindow.pageStack.columnView + + Kirigami.PageRoute { + name: "home" + Component { + Kirigami.Page { + // This page will show up when starting the application + } + } + } + Kirigami.PageRoute { + name: "login" + Component { + Kirigami.Page { + // Page contents... + } + } + } + } +} \ No newline at end of file diff --git a/examples/PageRouterRoutes.qml b/examples/PageRouterRoutes.qml new file mode 100644 --- /dev/null +++ b/examples/PageRouterRoutes.qml @@ -0,0 +1,34 @@ +import QtQuick 2.12 +import org.kde.kirigami 2.12 as Kirigami + +Kirigami.ApplicationWindow { + id: applicaionWindow + Kirigami.PageRouter { + pageStack: applicaionWindow.pageStack.columnView + + Kirigami.PageRoute { + name: "routeOne" + Component { + Kirigami.Page { + // Page contents... + } + } + } + Kirigami.PageRoute { + name: "routeTwo" + Component { + Kirigami.Page { + // Page contents... + } + } + } + Kirigami.PageRoute { + name: "routeThree" + Component { + Kirigami.Page { + // Page contents... + } + } + } + } +} \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,6 +27,7 @@ shadowedrectangle.cpp shadowedtexture.cpp colorutils.cpp + pagerouter.cpp scenegraph/shadowedrectanglenode.cpp scenegraph/shadowedrectanglematerial.cpp scenegraph/shadowedborderrectanglematerial.cpp diff --git a/src/controls/PageRow.qml b/src/controls/PageRow.qml --- a/src/controls/PageRow.qml +++ b/src/controls/PageRow.qml @@ -56,6 +56,17 @@ */ contentItem: columnView + /** + * columnView: Kirigami::ColumnView + * + * The ColumnView that this PageRow owns. + * Generally, you shouldn't need to change + * the value of this. + * + * @since 2.12 + */ + property alias columnView: columnView + /** * items: list * All the items that are present in the PageRow diff --git a/src/kirigamiplugin.cpp b/src/kirigamiplugin.cpp --- a/src/kirigamiplugin.cpp +++ b/src/kirigamiplugin.cpp @@ -20,6 +20,7 @@ #include "shadowedrectangle.h" #include "shadowedtexture.h" #include "colorutils.h" +#include "pagerouter.h" #include #include @@ -248,6 +249,9 @@ qmlRegisterSingletonType(uri, 2, 12, "ColorUtils", [] (QQmlEngine*, QJSEngine*) -> QObject* { return new ColorUtils; }); qmlRegisterUncreatableType(uri, 2, 12, "CornersGroup", QStringLiteral("Used as grouped property")); + qmlRegisterType(uri, 2, 12, "PageRouter"); + qmlRegisterType(uri, 2, 12, "PageRoute"); + qmlRegisterUncreatableType(uri, 2, 12, "PageRouterAttached", QStringLiteral("PageRouterAttached cannot be created")); qmlProtectModule(uri, 2); } diff --git a/src/pagerouter.h b/src/pagerouter.h new file mode 100644 --- /dev/null +++ b/src/pagerouter.h @@ -0,0 +1,465 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#pragma once + +#include +#include "columnview.h" + +struct ParsedRoute { + QString name; + QVariant data; + bool cache; + QObject* item; + bool operator==(const ParsedRoute& rhs) + { + return name == rhs.name && data == rhs.data && item == rhs.item && cache == rhs.cache; + } + bool operator!=(const ParsedRoute& rhs) + { + return name != rhs.name && data != rhs.data && item != rhs.item && cache != rhs.cache; + } +}; + +/** + * Item representing a route the PageRouter can navigate to. + * + * @include PageRoute.qml + * + * @see PageRouter + */ +class PageRoute : public QObject +{ + Q_OBJECT + + /** + * @brief The name of this route. + * + * This name should be unique per PageRoute in a PageRouter. + * When two PageRoutes have the same name, the one listed first + * in the PageRouter will be used. + */ + Q_PROPERTY(QString name MEMBER m_name READ name) + + /** + * @brief The page component of this route. + * + * This should be an instance of Component with a Kirigami::Page inside + * of it. + */ + Q_PROPERTY(QQmlComponent* component MEMBER m_component READ component) + + /** + * @brief Whether pages generated by this route should be cached or not. + * + * This should be an instance of Component with a Kirigami::Page inside + * of it. + * + * This will not work: + * + * @include PageRouterCachePagesDont.qml + * + * This will work: + * + * @include PageRouterCachePagesDo.qml + * + */ + Q_PROPERTY(bool cache MEMBER m_cache READ cache) + + Q_CLASSINFO("DefaultProperty", "component") + +private: + QString m_name; + QQmlComponent* m_component; + bool m_cache = false; + +public: + QQmlComponent* component() { return m_component; }; + QString name() { return m_name; }; + bool cache() { return m_cache; }; +}; + +class PageRouterAttached; + +/** + * An item managing pages and data of a ColumnView using named routes. + * + *

+ * + * ## Using a PageRouter + * + * Applications typically manage their contents via elements called "pages" or "screens." + * In Kirigami, these are called @link org::kde::kirigami::Page Pages @endlink and are + * arranged in @link PageRoute routes @endlink using a PageRouter to manage them. The PageRouter + * manages a stack of @link org::kde::kirigami::Page Pages @endlink created from a pool of potential + * @link PageRoute PageRoutes @endlink. + * + * Unlike most traditional stacks, a PageRouter provides functions for random access to its pages + * with navigateToRoute and routeActive. + * + * When your user interface fits the stack paradigm and is likely to use random access navigation, + * using the PageRouter is appropriate. For simpler navigation, it is more appropriate to avoid + * the overhead of a PageRouter by using a @link org::kde::kirigami::PageRow PageRow @endlink + * instead. + * + *

+ * + * ## Navigation Model + * + * A PageRouter draws from a pool of @link PageRoute PageRoutes @endlink in order to construct + * its stack. + * + * @image html PageRouterModel.svg width=50% + * + *

+ * + * You can push pages onto this stack... + * + * @image html PageRouterPush.svg width=50% + * + * ...or pop them off... + * + * @image html PageRouterPop.svg width=50% + * + * ...or navigate to an arbitrary collection of pages. + * + * @image html PageRouterNavigate.svg width=50% + * + *

+ * + * Components are able to query the PageRouter about the currently active routes + * on the stack. This is useful for e.g. a card indicating that the page it takes + * the user to is currently active. + * + *

+ * + * ## Example + * + * @include PageRouter.qml + * + * @see PageRouterAttached + * @see PageRoute + */ +class PageRouter : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + /** + * @brief The named routes a PageRouter can navigate to. + * + * @include PageRouterRoutes.qml + */ + Q_PROPERTY(QQmlListProperty routes READ routes) + + Q_CLASSINFO("DefaultProperty", "routes") + + /** + * @brief The initial route. + * + * `initialRoute` is the page that the PageRouter will push upon + * creation. Changing it after creation will cause the PageRouter to reset + * its state. Not providing an `initialRoute` will result in undefined + * behavior. + * + * @include PageRouterInitialRoute.qml + */ + Q_PROPERTY(QJSValue initialRoute READ initialRoute WRITE setInitialRoute NOTIFY initialRouteChanged) + + /** + * @brief The ColumnView being puppeted by the PageRouter. + * + * All PageRouters should be created with a ColumnView, and creating one without + * a ColumnView is undefined behaviour. + * + * @warning You should **not** directly interact with a ColumnView being puppeted + * by a PageRouter. Instead, use a PageRouter's functions to manipulate the + * ColumnView. + * + * @include PageRouterColumnView.qml + */ + Q_PROPERTY(ColumnView* pageStack MEMBER m_pageStack NOTIFY pageStackChanged) + +private: + /** + * @brief The routes the PageRouter is aware of. + * + * Generally, this should not be mutated from C++, only read. + */ + QList m_routes; + + /** + * @brief The PageRouter being puppeted. + * + * m_pageRow is the ColumnView this PageRouter puppets. + */ + ColumnView* m_pageStack = nullptr; + + /** + * @brief The route that the PageRouter will load on completion. + * + * m_initialRoute is the raw QJSValue from QML that will be + * parsed into a ParsedRoute struct on construction. + * Generally, this should not be mutated from C++, only read. + */ + QJSValue m_initialRoute; + + /** + * @brief The current routes pushed on the PageRow. + * + * Generally, the state of m_pageRow and m_currentRoutes + * should be kept in sync. Undesirable behaviour will result + * from desynchronisation of the two. + */ + QList m_currentRoutes; + + /** + * @brief Cached routes. + * + * A list of ParsedRoutes with instantiated items. + */ + QList m_cachedRoutes; + + /** + * @brief Helper function to push a route. + * + * This function has the shared logic between + * navigateToRoute and pushRoute. + */ + void push(ParsedRoute route); + + /** + * @brief Helper function to access whether m_routes has a key. + * + * This function abstracts the QJSValue. + */ + bool routesContainsKey(const QString &key); + + /** + * @brief Helper function to access the component of a key for m_routes. + * + * The return value will be a nullptr if @p key does not exist in + * m_routes. + */ + QQmlComponent *routesValueForKey(const QString &key); + + /** + * @brief Helper function to access the cache status of a key for m_routes. + * + * The return value will be false if @p key does not exist in + * m_routes. + */ + bool routesCacheForKey(const QString &key); + + static void appendRoute(QQmlListProperty* list, PageRoute*); + static int routeCount(QQmlListProperty* list); + static PageRoute* route(QQmlListProperty* list, int); + static void clearRoutes(QQmlListProperty* list); + + QVariant dataFor(QObject* object); + bool isActive(QObject* object); + + friend class PageRouterAttached; + +protected: + void classBegin() override; + void componentComplete() override; + +public: + PageRouter(QQuickItem *parent = nullptr); + ~PageRouter(); + + QQmlListProperty routes(); + + QJSValue initialRoute() const; + void setInitialRoute(QJSValue initialRoute); + + /** + * @brief Navigate to the given route. + * + * Calling `navigateToRoute` causes the PageRouter to replace currently + * active pages with the new route. + * + * @param route The given route for the PageRouter to navigate to. + * A route is an array of variants or a single item. A string item will be interpreted + * as a page without associated data. An object item will be interpreted + * as follows: + * @code{.js} + * { + * "route": "/home" // The named page of the route. + * "data": QtObject {} // The data to pass to the page. + * } + * @endcode + * Navigating to a route not defined in a PageRouter's routes is undefined + * behavior. + * + * @code{.qml} + * Button { + * text: "Login" + * onClicked: { + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * } + * } + * @endcode + */ + Q_INVOKABLE void navigateToRoute(QJSValue route); + + /** + * @brief Check whether the current route is on the stack. + * + * `routeActive` will return true if the given route + * is on the stack. + * + * @param route The given route to check for. + * + * `routeActive` returns true for partial routes like + * the following: + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login", "/google"]) + * Kirigami.PageRouter.routeActive(["/home", "/login"]) // returns true + * @endcode + * + * This only works from the root page, e.g. the following will return false: + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login", "/google"]) + * Kirigami.PageRouter.routeActive(["/login", "/google"]) // returns false + * @endcode + */ + Q_INVOKABLE bool routeActive(QJSValue route); + + /** + * @brief Appends a route to the currently navigated route. + * + * Calling `pushRoute` will append the given @p route to the currently navigated + * routes. See navigateToRoute() if you want to replace the items currently on + * the PageRouter. + * + * @param route The given route to push. + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * // The PageRouter is navigated to /home/login + * Kirigami.PageRouter.pushRoute("/google") + * // The PageRouter is navigated to /home/login/google + * @endcode + */ + Q_INVOKABLE void pushRoute(QJSValue route); + + /** + * @brief Pops the last page on the router. + * + * Calling `popRoute` will result in the last page on the router getting popped. + * You should not call this function when there is only one page on the router. + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * // The PageRouter is navigated to /home/login + * Kirigami.PageRouter.popRoute() + * // The PageRouter is navigated to /home + * @endcode + */ + Q_INVOKABLE void popRoute(); + + /** + * @brief Shifts keyboard focus and view to a given index on the PageRouter's stack. + * + * @param view The view to bring to focus. If this is an integer index, the PageRouter will + * navigate to the given index. If it's a route specifier, the PageRouter will navigate + * to the first route matching it. + * + * Navigating to route by index: + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/browse", "/apps", "/login"]) + * Kirigami.PageRouter.bringToView(1) + * @endcode + * + * Navigating to route by name: + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/browse", "/apps", "/login"]) + * Kirigami.PageRouter.bringToView("/browse") + * @endcode + * + * Navigating to route by data: + * @code{.js} + * Kirigami.PageRouter.navigateToRoute([{"route": "/page", "data": "red"}, + * {"route": "/page", "data": "blue"}, + * {"route": "/page", "data": "green"}, + * {"route": "/page", "data": "yellow"}]) + * Kirigami.PageRouter.bringToView({"route": "/page", "data": "blue"}) + * @endcode + */ + Q_INVOKABLE void bringToView(QJSValue route); + + /** + * @brief Returns a QJSValue corresponding to the current pages on the stack. + * + * The returned value is in the same form as the input to navigateToRoute. + */ + Q_INVOKABLE QJSValue currentRoutes() const; + + static PageRouterAttached *qmlAttachedProperties(QObject *object); + +Q_SIGNALS: + void routesChanged(); + void initialRouteChanged(); + void pageStackChanged(); + void currentIndexChanged(); +}; + +/** + * Attached object allowing children of a PageRouter to access its functions + * without requiring the children to have the parent PageRouter's id. + * + * @see PageRouter + */ +class PageRouterAttached : public QObject +{ + Q_OBJECT + + Q_PROPERTY(PageRouter *router READ router NOTIFY routerChanged) + /** + * The data for the page this item belongs to. Accessing this property + * outside of a PageRouter will result in undefined behavior. + */ + Q_PROPERTY(QVariant data READ data MEMBER m_data NOTIFY dataChanged) + + /** + * Whether the page this item belongs to is the current index of the ColumnView. + * Accessing this property outside of a PageRouter will result in undefined behaviour. + */ + Q_PROPERTY(bool isCurrent READ isCurrent NOTIFY isCurrentChanged) + +private: + explicit PageRouterAttached(QObject *parent = nullptr); + + QPointer m_router; + QVariant m_data; + + friend class PageRouter; + +public: + PageRouter* router() const { return m_router; }; + QVariant data() const; + bool isCurrent() const; + /// @see PageRouter::navigateToRoute() + Q_INVOKABLE void navigateToRoute(QJSValue route) { m_router->navigateToRoute(route); }; + /// @see PageRouter::routeActive() + Q_INVOKABLE bool routeActive(QJSValue route) { return m_router->routeActive(route); }; + /// @see PageRouter::pushRoute() + Q_INVOKABLE void pushRoute(QJSValue route) { m_router->pushRoute(route); }; + /// @see PageRouter::popRoute() + Q_INVOKABLE void popRoute() { m_router->popRoute(); }; + // @see PageRouter::bringToView() + Q_INVOKABLE void bringToView(QJSValue route) { m_router->bringToView(route); }; + +Q_SIGNALS: + void routerChanged(); + void dataChanged(); + void isCurrentChanged(); +}; + +QML_DECLARE_TYPEINFO(PageRouter, QML_HAS_ATTACHED_PROPERTIES) diff --git a/src/pagerouter.cpp b/src/pagerouter.cpp new file mode 100644 --- /dev/null +++ b/src/pagerouter.cpp @@ -0,0 +1,387 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#include +#include +#include +#include +#include +#include "pagerouter.h" + +ParsedRoute parseRoute(QJSValue value) +{ + if (value.isUndefined()) { + return ParsedRoute{QString(), QVariant(), false, nullptr}; + } else if (value.isString()) { + return ParsedRoute{ + value.toString(), + QVariant(), + false, + nullptr + }; + } else { + return ParsedRoute{ + value.property(QStringLiteral("route")).toString(), + value.property(QStringLiteral("data")).toVariant(), + false, + nullptr + }; + } +} + +QList parseRoutes(QJSValue values) +{ + QList ret; + if (values.isArray()) { + for (auto route : values.toVariant().toList()) { + if (route.toString() != QString()) { + ret << ParsedRoute{ + route.toString(), + QVariant(), + false, + nullptr + }; + } else if (route.canConvert()) { + auto map = route.value(); + ret << ParsedRoute{ + map.value(QStringLiteral("route")).toString(), + map.value(QStringLiteral("data")), + false, + nullptr + }; + } + } + } else { + ret << parseRoute(values); + } + return ret; +} + +PageRouter::PageRouter(QQuickItem *parent) : QObject(parent) +{ + connect(this, &PageRouter::pageStackChanged, [=]() { + connect(m_pageStack, &ColumnView::itemRemoved, [=](QQuickItem *item) { + QList toRemove; + for (auto route : m_currentRoutes) { + if (route.item == qobject_cast(item)) { + if (!route.cache) { + route.item->deleteLater(); + } + } + } + for (auto route : toRemove) { + m_currentRoutes.removeAll(route); + } + }); + connect(m_pageStack, &ColumnView::currentIndexChanged, this, &PageRouter::currentIndexChanged); + }); +} + +QQmlListProperty PageRouter::routes() +{ + return QQmlListProperty(this, nullptr, appendRoute, routeCount, route, clearRoutes); +} + +void PageRouter::appendRoute(QQmlListProperty* prop, PageRoute* route) +{ + auto router = qobject_cast(prop->object); + router->m_routes.append(route); +} + +int PageRouter::routeCount(QQmlListProperty* prop) +{ + auto router = qobject_cast(prop->object); + return router->m_routes.length(); +} + +PageRoute* PageRouter::route(QQmlListProperty* prop, int index) +{ + auto router = qobject_cast(prop->object); + return router->m_routes[index]; +} + +void PageRouter::clearRoutes(QQmlListProperty* prop) +{ + auto router = qobject_cast(prop->object); + router->m_routes.clear(); +} + +PageRouter::~PageRouter() {} + +void PageRouter::classBegin() +{ + +} + +void PageRouter::componentComplete() +{ + if (m_pageStack == nullptr) { + qCritical() << "PageRouter should be created with a ColumnView. Not doing so is undefined behaviour, and is likely to result in a crash upon further interaction."; + } else { + Q_EMIT pageStackChanged(); + m_currentRoutes.clear(); + push(parseRoute(initialRoute())); + } +} + +bool PageRouter::routesContainsKey(const QString &key) +{ + for (auto route : m_routes) { + if (route->name() == key) return true; + } + return false; +} + +QQmlComponent* PageRouter::routesValueForKey(const QString &key) +{ + for (auto route : m_routes) { + if (route->name() == key) return route->component(); + } + return nullptr; +} + +bool PageRouter::routesCacheForKey(const QString &key) +{ + for (auto route : m_routes) { + if (route->name() == key) return route->cache(); + } + return false; +} + +void PageRouter::push(ParsedRoute route) +{ + if (!routesContainsKey(route.name)) { + qCritical() << "Route" << route.name << "not defined"; + return; + } + if (routesCacheForKey(route.name)) { + for (auto cachedRoute : m_cachedRoutes) { + if (cachedRoute.name == route.name && cachedRoute.data == route.data) { + m_currentRoutes << cachedRoute; + m_pageStack->addItem(qobject_cast(cachedRoute.item)); + return; + } + } + } + auto context = qmlContext(this); + auto component = routesValueForKey(route.name); + auto createAndPush = [component, context, route, this]() { + // We use beginCreate and completeCreate to allow + // for a PageRouterAttached to find its parent + // on construction time. + auto item = component->beginCreate(context); + item->setParent(this); + auto clone = route; + clone.item = item; + clone.cache = routesCacheForKey(route.name); + m_currentRoutes << clone; + if (routesCacheForKey(route.name)) { + m_cachedRoutes << clone; + } + component->completeCreate(); + m_pageStack->addItem(qobject_cast(item)); + m_pageStack->setCurrentIndex(m_currentRoutes.length()-1); + }; + + if (component->status() == QQmlComponent::Ready) { + createAndPush(); + } else if (component->status() == QQmlComponent::Loading) { + connect(component, &QQmlComponent::statusChanged, [=](QQmlComponent::Status status) { + // Loading can only go to Ready or Error. + if (status != QQmlComponent::Ready) { + qCritical() << "Failed to push route:" << component->errors(); + } + createAndPush(); + }); + } else { + qCritical() << "Failed to push route:" << component->errors(); + } +} + +QJSValue PageRouter::initialRoute() const +{ + return m_initialRoute; +} + +void PageRouter::setInitialRoute(QJSValue value) +{ + m_initialRoute = value; +} + +void PageRouter::navigateToRoute(QJSValue route) +{ + auto incomingRoutes = parseRoutes(route); + QList resolvedRoutes; + + if (incomingRoutes.length() <= m_currentRoutes.length()) { + resolvedRoutes = m_currentRoutes.mid(0, incomingRoutes.length()); + } else { + resolvedRoutes = m_currentRoutes; + resolvedRoutes.reserve(incomingRoutes.length()-m_currentRoutes.length()); + } + + for (int i = 0; i < incomingRoutes.length(); i++) { + auto current = resolvedRoutes.value(i); + auto incoming = incomingRoutes.at(i); + if (i >= resolvedRoutes.length()) { + resolvedRoutes.append(incoming); + } else if (current != incoming) { + resolvedRoutes.replace(i, incoming); + } + } + + for (auto route : m_currentRoutes) { + if (!resolvedRoutes.contains(route)) { + if (!route.cache) { + route.item->deleteLater(); + } + } + } + + m_pageStack->clear(); + m_currentRoutes.clear(); + for (auto toPush : resolvedRoutes) { + push(toPush); + } +} + +void PageRouter::bringToView(QJSValue route) +{ + if (route.isNumber()) { + auto index = route.toNumber(); + m_pageStack->setCurrentIndex(index); + } else { + auto parsed = parseRoute(route); + auto index = 0; + for (auto currentRoute : m_currentRoutes) { + if (currentRoute.name == parsed.name && currentRoute.data == parsed.data) { + m_pageStack->setCurrentIndex(index); + return; + } + index++; + } + qWarning() << "Route" << parsed.name << "with data" << parsed.data << "is not on the current stack of routes."; + } +} + +bool PageRouter::routeActive(QJSValue route) +{ + auto parsed = parseRoutes(route); + if (parsed.length() > m_currentRoutes.length()) { + return false; + } + for (int i = 0; i < parsed.length(); i++) { + if (parsed[i].name != m_currentRoutes[i].name) { + return false; + } + if (parsed[i].data.isValid()) { + if (parsed[i].data != m_currentRoutes[i].data) { + return false; + } + } + } + return true; +} + +void PageRouter::pushRoute(QJSValue route) +{ + push(parseRoute(route)); +} + +void PageRouter::popRoute() +{ + m_pageStack->pop(qobject_cast(m_currentRoutes.last().item)); + if (!m_currentRoutes.last().cache) { + m_currentRoutes.last().item->deleteLater(); + } + m_currentRoutes.removeLast(); +} + +QVariant PageRouter::dataFor(QObject *object) +{ + auto pointer = object; + while (pointer != nullptr) { + for (auto route : m_currentRoutes) { + if (route.item == pointer) { + return route.data; + } + } + pointer = pointer->parent(); + } + return QVariant(); +} + +bool PageRouter::isActive(QObject *object) +{ + auto pointer = object; + while (pointer != nullptr) { + auto index = 0; + for (auto route : m_currentRoutes) { + if (route.item == pointer) { + return m_pageStack->currentIndex() == index; + } + index++; + } + pointer = pointer->parent(); + } + qWarning() << "Object" << object << "not in current routes"; + return false; +} + +PageRouterAttached* PageRouter::qmlAttachedProperties(QObject *object) +{ + auto attached = new PageRouterAttached(object); + auto pointer = object; + // Climb the parent tree to find our parent PageRouter + while (pointer != nullptr) { + auto casted = qobject_cast(pointer); + if (casted != nullptr) { + attached->m_router = casted; + connect(casted, &PageRouter::currentIndexChanged, attached, &PageRouterAttached::isCurrentChanged); + break; + } + pointer = pointer->parent(); + } + if (attached->m_router.isNull()) { + qCritical() << "PageRouterAttached could not find a parent PageRouter"; + } + return attached; +} + +QVariant PageRouterAttached::data() const +{ + if (m_router) { + return m_router->dataFor(parent()); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return QVariant(); + } +} + +bool PageRouterAttached::isCurrent() const +{ + if (m_router) { + return m_router->isActive(parent()); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return false; + } +} + +QJSValue PageRouter::currentRoutes() const +{ + auto engine = qjsEngine(this); + auto ret = engine->newArray(m_currentRoutes.length()); + for (int i = 0; i < m_currentRoutes.length(); ++i) { + auto object = engine->newObject(); + object.setProperty(QStringLiteral("route"), m_currentRoutes[i].name); + object.setProperty(QStringLiteral("data"), engine->toScriptValue(m_currentRoutes[i].data)); + ret.setProperty(i, object); + } + return ret; +} + +PageRouterAttached::PageRouterAttached(QObject *parent) : QObject(parent) {} \ No newline at end of file