knitr::opts_chunk$set( collapse = TRUE, comment = "#>" )
library(shiny) #library(brochure) library(shinyMobile)
Disclaimer: all of the following is still very experimental. Use it with caution.
Since v2.0.0, {shinyMobile}
has multi pages support. Under the hood, this amazing feature is made possible owing to the {brochure}
package from
Colin Fay as well as the internal Framework7 router.
What is multi pages navigation? If you consider a basic website with 2 pages such as index.html
and other.html
, browsing to https:://mywebsite.com
opens index.html
while typing https:://mywebsite.com/other.html
requests the other.html
page. If you take a classic shiny app with tabs, clicking on one tab gives you the illusion to browse to another page because of the Bootstrap JS and CSS magic. That's however not the case as the url does not change. Therefore, Shiny doesn't support multi pages navigation by default. {brochure}
makes this somehow possible.
To develop a {brochure}
app, you need the following template:
page_1 <- function() { page( href = "/", ui = function(request) { # PAGE 1 UI }, server = function(input, output, session) { # Server function } ) } page_2 <- function() { page( href = "/2", ui = function(request) { # Page 2 UI }, server = function(input, output, session) { # Server function } ) } page_3 <- function() { page( href = "/3", ui = function(request) { # Page 3 UI }, server = function(input, output, session) { # Server function } ) } brochureApp( # Pages page_1(), page_2(), page_3(), wrapped = <WRAPPER-FUNC> )
In brief, you create different pages with page()
and put them inside brochureApp()
. Each page is composed of a ui
, server
and href
which represents the location where to serve the page. In theory, as mentioned in the {brochure}
documentation, each page has its own shiny session, which means that if you go from page 1 to page 2, the state of page 1 is lost when you come back to it.
For {shinyMobile}
, we decided to slightly deviate from this and only assign a global server function, meaning that you can use the brochureApp()
server
parameter instead of the one from page()
. This requires to install a modified version of
{brochure}
:
devtools::install_github("DivadNojnarg/brochure")
Besides, brochureApp()
exposes wrapped, allowing us to inject our own f7MultiLayout()
function, described below with more details.
f7MultiLayout()
accepts elements of a certain layout, similar to what is exposed by the f7SingleLayout()
:
shiny::tags$div( class = "page", # top navbar goes here f7Navbar(title = "Home page"), # Optional toolbar # tags$div( class = "page-content", ... ) )
page is the main wrapper which takes a navbar and page-content as children. Note that you can also have a local toolbar, assuming you didn't define a main toolbar in f7MultiLayout()
. Indeed, if you pass a toolbar to the corresponding f7MultiLayout()
parameter, it is seen as a global app toolbar. page-content is where is displayed the page content such as {shinyMobile}
widgets.
f7MultiLayout()
accepts a list of app options, like f7DefaultOptions()
, which are internally forwarded to f7Page()
.
At the time of writting of this vignette, you must install a patched {brochure}
version with devtools::install_github("DivadNojnarg/brochure")
to be able to pass custom options to brochureApp()
, which essentially calls do.call(wrapped, wrapped_options)
:
brochureApp( # Pages page_1(), page_2(), page_3(), wrapped = <WRAPPER-FUNC> wrapped_options = <OPTIONS> )
Internally, f7MultiLayout()
wraps all the pages in a view reponsible for the page navigation and history (going back and forward with the browser back/next buttons). This view is also called router.
(At this point, you may wonder what is basepath
. We come back on that later in the article)
# f7MultiLayout shiny::tags$div( class = "view view-main view-init", # When app is deployed, the basepath isn't / but something else ... `data-url` = basepath, # Avoids to see the previous page in the DOM `data-preload-previous-page` = "false", # Important: to be able to have updated url `data-browser-history` = "true", # Avoids the ugly #! default separator `data-browser-history-separator` = "", # Optional common toolbar toolbar, ... )
In addition to creating the pages UI, we need to tell Framework7 how to route the pages. This is pretty simple, as we can pass a list of routes which is sent to JS with the app options list. This may yield something like this:
# Framework7 options: see f7DefaultOptions() options = list( # dark mode option dark = TRUE, routes = list( # Important: don't remove keepAlive # for pages as this allows # to save the input state when switching # between pages. If FALSE, each time a page is # changed, inputs are reset. list(path = "/", url = "/", name = "home", keepAlive = TRUE), list(path = "/2", url = "/2", name = "2", keepAlive = TRUE), list(path = "/3", url = "/3", name = "3", keepAlive = TRUE) ) )
For each route, we must provide:
TRUE
. See more here.At some point, you want to deploy your app on a server. The url could be something like
https://<user_name>.shinyapps.io/<app_name>
if your using shinyapps.io
as hosting system.
In that case, we must adapt all the links, the one provided in the router configuration and in the different pages
to account for the base location (basepath
) that is /<app_name
and not just /
as you would have
locally.
We create a config.yml file to handle the different basepath
so that the link navigation work:
default: basepath: "" shinyapps: basepath: "/multipages"
We design a helper function to handle the link update:
# Allows to use the app on a server like # shinyapps.io where basepath is /app_name # instead of "/" or "". make_link <- function(path = NULL) { if (is.null(path)) { if (nchar(config::get()$basepath) > 0) { return(config::get()$basepath) } else { return("/") } } sprintf("%s/%s", config::get()$basepath, path) }
As you can see, we leverage the {config}
package to recover the basepath
value.
Once deployed, the app knows that on shinyapps.io
the basepath will be /multipages
.
We can then update our router config with make_link
. Note that for the first page,
we use make_link()
that will either return /
(locally) or /<app_path>
on a server.
For other pages, we just call make_link("2")
which yields either /2
locally or /<app_path>/2
on a server:
# Framework7 options: see f7DefaultOptions() options = list( # dark mode option dark = TRUE, routes = list( # Important: don't remove keepAlive # for pages as this allows # to save the input state when switching # between pages. If FALSE, each time a page is # changed, inputs are reset. list(path = make_link(), url = make_link(), name = "home", keepAlive = TRUE), list(path = make_link("2"), url = make_link("2"), name = "2", keepAlive = TRUE), list(path = make_link("3"), url = make_link("3"), name = "3", keepAlive = TRUE) ) )
When you call brochureApp
, don't forget to pass the basepath
parameter
(or alternatively make_link()
):
brochureApp( basepath = config::get()$basepath, # Pages page_1(), page_2(), page_3(), wrapped = <WRAPPER-FUNC> # This list is passed to <WRAPPER-FUNC> wrapped_options = list( basepath = make_link(), toolbar = <TOOLBAR>, options = # Framework7 options see above # ) )
let's build our simple app. It has 3 pages, the welcome page and 2 other
pages. We create the navigation links as follows with the f7Link()
function
and allowing routable
, wrapping them with make_link
:
links <- lapply(2:3, function(i) { tags$li( f7Link( routable = TRUE, label = sprintf("Link to page %s", i), href = make_link(sprintf("/%s", i)) ) ) })
Importantly, the href must point to the right location passed in the routes options, as
previously described (here it is /2
and /3
).
The first page is a function wrapped by the {brochure}
page()
function containing:
/
that is the root page.As you may notice, here is the only place where we don't need to wrap
href
with make_link
.
page_1 <- function() { page( href = "/", ui = function(request) { shiny::tags$div( class = "page", # top navbar goes here f7Navbar(title = "Home page"), tags$div( class = "page-content", f7List( inset = TRUE, strong = TRUE, outline = TRUE, dividers = TRUE, mode = "links", links ), f7Block( f7Text("text", "Text input", "default"), f7Select("select", "Select", colnames(mtcars)), textOutput("res"), textOutput("res2") ) ) ) } ) }
links
are wrapped within a f7List()
which has better styling options.
The second page follows the same layout. Notice that we could pass it a local toolbar
but
decided to use the global toolbar from f7Multilayout()
so all pages share the same
toolbar. This choice is up to your preference. A cool new feature from
{shinyMobile}
2.0.0 is the ability to pass tags to the f7Navbar()
left parameter, which
make it possible to add a back button link. Don't forget the back
css class so that
the router transition looks correct:
page_2 <- function() { page( href = "/2", ui = function(request) { shiny::tags$div( class = "page", # top navbar goes here f7Navbar( title = "Second page", # Allows to go back to main leftPanel = tagList( tags$a( href = make_link(), class = "link back", tags$i(class = "icon icon-back"), tags$span( class = "if-not-md", "Back" ) ) ) ), # NOTE: when the main toolbar is enabled in # f7MultiLayout, we can't use individual page toolbars. # f7Toolbar( # position = "bottom", # tags$a( # href = "/", # "Main page", # class = "link" # ) # ), shiny::tags$div( class = "page-content", f7Block(f7Button(inputId = "update", label = "Update stepper")), f7List( strong = TRUE, inset = TRUE, outline = FALSE, f7Stepper( inputId = "stepper", label = "My stepper", min = 0, max = 10, size = "small", value = 4, wraps = TRUE, autorepeat = TRUE, rounded = FALSE, raised = FALSE, manual = FALSE ) ), f7Block(textOutput("test")) ) ) } ) }
The page contains a f7Stepper()
which is an improved numeric input.
Finally, you'll find the third page code in the global app code below.
The main server function contains all the logic related to widgets you can
find in all pages and passed to the brochureApp()
server parameter:
server = function(input, output, session) { output$res <- renderText(input$text) output$res2 <- renderText(input$select) output$test <- renderText(input$stepper) observeEvent(input$update, { updateF7Stepper( inputId = "stepper", value = 0.1, step = 0.01, size = "large", min = 0, max = 1, wraps = FALSE, autorepeat = FALSE, rounded = TRUE, raised = TRUE, color = "pink", manual = TRUE, decimalPoint = 2 ) }) }
Notice that the global app toolbar is passed with the wrapper_options
parameter, the reason being it has to be injected in the f7MultiLayout()
wrapper. We also
have to pass the basepath
paramter for the f7MultiLayout
wrapper function:
wrapped_options = list( basepath = make_link(), # Common toolbar toolbar = f7Toolbar( f7Link(icon = f7Icon("house"), href = make_link(), routable = TRUE) ), # Other options ... )
As you can see, that wasn't tricky to setup such a layout and the entire working example is shown below.
You might find more convenient to assign a Shiny module per page such as:
page_ui <- function(id) { ns <- shiny::NS(id) tags$div( class = "page", f7Navbar(title = "Navbar"), tags$div( class = "page-content", f7Block( inset = TRUE, strong = TRUE, f7Text(ns("text"), "A text input", "Super text!"), textOutput(ns("res")) ) ) ) } page_server <- function(id) { moduleServer( id, function(input, output, session) { output$res <- renderText(input$text) } ) } my_page <- function() { page( href = "/", ui = page_ui("page1") ) } brochureApp( basepath = config::get()$basepath, my_page(), server = function(input, output, session) { # Call modules here page_server("page1") }, wrapped = f7MultiLayout, wrapped_options = list( basepath = make_link(), # Common toolbar toolbar = f7Toolbar( f7Link(icon = f7Icon("house"), href = make_link(), routable = TRUE) ), options = list( dark = TRUE, theme = "md", routes = list( # Important: don't remove keepAlive # for pages as this allows # to save the input state when switching # between pages. If FALSE, each time a page is # changed, inputs are reset. list(path = make_link(), url = make_link(), name = "home", keepAlive = TRUE) ) ) ) )
How do we dynamically create new routes? Since {brochure}
stores all the pages in the ...pages
environment, we can add entries to it.
In the following example, we add a /new
page which has a basic UI and then browse to the corresponding url.
We then leverage session$sendCustomMessage
to send the page href
from R to JS and Shiny.addCustomMessageHandler
on the JS side to pass it to window.open
, with the _self
option (meaning that we open in the same window).
brochureApp( # First page tags$script( "$(function() { $(document).on('shiny:connected', function() { Shiny.addCustomMessageHandler('browse', function(m) { window.open(window.location.href + m, '_self'); }); }); });" ), page( href = "/", ui = fluidPage( h1("This is my first page"), plotOutput("plot"), actionButton("add", "Add page") ) ), # Second page, without any server-side function page( href = "/page2", ui = fluidPage( h1("This is my second page"), tags$p("There is no server function in this one") ) ), server = function(input, output, session) { output$plot <- renderPlot({ plot(iris) }) observeEvent(input$add, { if (!("/new" %in% names(...pages))) { ...pages[["/new"]]$ui <- fluidPage("New dynamic page") } session$sendCustomMessage("browse", "new") }) } )
How does that translate for {shinyMobile}
? Let's take a simple app with one page.
We define the routes outside the brochureApp
, as we'll have to update the options from
within the server function. Initially, we consider one route. By clicking on the add button, we add a new page to ...pages[["/new"]]$ui
, update the global options to account for the new route on the R side and use updateF7Routes(options$routes)
to update the router on the JS side and have the navigation working:
options <- list( dark = TRUE, theme = "md", routes = list( list(path = "/", url = "/", name = "home", keepAlive = TRUE) ) ) page_1 <- function() { page( href = "/", ui = function(request) { shiny::tags$div( class = "page", # top navbar goes here f7Navbar(title = "Home page"), tags$div( class = "page-content", f7Block( f7Button("add", "Add new") ), f7Block( f7Link("New", href = "/new", routable = TRUE) ) ) ) } ) } brochureApp( page_1(), server = function(input, output, session) { observeEvent(input$add, { if (!("/new" %in% names(...pages))) { ...pages[["/new"]]$ui <- shiny::tags$div( class = "page", # top navbar goes here f7Navbar( title = "New page", # Allows to go back to main leftPanel = tagList( tags$a( href = "/", class = "link back", tags$i(class = "icon icon-back"), tags$span( class = "if-not-md", "Back" ) ) ) ), tags$div( class = "page-content", f7Block("Nothing here") ) ) options$routes[[length(options$routes) + 1]] <- list(path = "/new", url = "/new", name = "new", keepAlive = TRUE) updateF7Routes(options$routes) } }) }, wrapped = f7MultiLayout, wrapped_options = list( # Common toolbar toolbar = f7Toolbar( f7Link(icon = f7Icon("house"), href = "/", routable = TRUE) ), options = options ) )
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.