Skip to contents
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):

brochureApp(
  # Pages
  page_1(),
  page_2(),
  page_3(),
  wrapped = <WRAPPER-FUNC>
  wrapped_options = <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:

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 #
  )
)

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
  )
)