library(shiny)
#library(brochure)
library(shinyMobile)
Introduction
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.
About brochure
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.
The new f7MultiLayout
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)
:
Framework 7 router
Local version
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:
- path: path display in the url.
- url: page url. In our case, this is identical to the path.
-
keepAlive: this ensures that when we leave the
current page and come back, we don’t lose the state of inputs and
widgets inside. Hence, it is expected to be
TRUE
. See more here.
Deploying on a server
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:
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()
):
A simple multi pages app
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:
- a ui component with a navbar, the page content.
-
href must be
/
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.
library(shiny)
# Needs a specific version of brochure for now.
# This allows to pass wrapper functions with options
# as list. We need it because of the f7Page options parameter
# and to pass the routes list object for JS.
# devtools::install_github("DivadNojnarg/brochure")
library(brochure)
library(shinyMobile)
# 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)
}
links <- lapply(2:3, function(i) {
tags$li(
f7Link(
routable = TRUE,
label = sprintf("Link to page %s", i),
href = make_link(i)
)
)
})
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")
)
)
)
}
)
}
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"))
)
)
}
)
}
page_3 <- function() {
page(
href = "/3",
ui = function(request) {
shiny::tags$div(
class = "page",
# top navbar goes here
f7Navbar(
title = "Third 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 = "/2",
# "Second page",
# class = "link"
# )
# ),
shiny::tags$div(
class = "page-content",
f7Block("Nothing to show yet ...")
)
)
}
)
}
brochureApp(
basepath = make_link(),
# Pages
page_1(),
page_2(),
page_3(),
# Important: in theory brochure makes
# each page having its own shiny session/ server function.
# That's not what we want here so we'll have
# a global server function.
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
)
})
},
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),
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)
)
)
)
)
Going further
With shiny modules
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)
)
)
)
)
Dynamic routes
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
)
)