Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Turbo Native & Strada: Turning a web-only Rails app native!

Turbo Native & Strada: Turning a web-only Rails app native!

Going Native: We go step-by-step to turn our web-only Rails app native using Turbo Native & Strada

As presented in the Ruby Meetup Frankfurt on April 16th, 2024.

- Find the video: https://youtu.be/BDBjgGSQA_4
- Find the repositories: https://github.com/kevkev300/personal_knowledge_base
- Find the meetup: https://www.meetup.com/frankfurt-ruby-meetup/events/299586276/

Kevin Liebholz

April 22, 2024
Tweet

More Decks by Kevin Liebholz

Other Decks in Programming

Transcript

  1. "QQ*OUSP # view (e.g., show) <%= turbo_stream_from @model %> #

    model.rb broadcasts_refreshes # child.rb belongs_to :model, touch: true broadcasts_refreshes # application.html.erb <head> <%= turbo_refreshes_with method: :morph %> <%= turbo_refresh_scroll_tag :preserve %> </head> # Gemfile gem 'turbo-rails', '~> 2.0.0.pre.beta' # use Turbo8
  2. • 0OF"QQUP$POUSPM5IFN"MM • 8FC • "OESPJE • J04 • 7FSTJPODIBOHFBQQTUPSFBDDFQUBODFQSPDFTT

    • 4QFDJGJDBQQWJFXT • &WFOOPOOBUJWFDPEFSTDBOEPJU • 0VUPGUIFCPYXJUIUVSCPSBJMTHFN • UVSCPJPTQBDLBHF 5VSCP/BUJWF
  3.  %PXOMPBE9DPEF J04EFWFMPQNFOU  *OTUBMMUVSCPJPTQBDLBHF IUUQTHJUIVCDPNIPUXJSFEUVSCPJPT   %0/& 5VSCP/BUJWF

     $SFBUFJ04"QQ  %PXOMPBE9DPEF J04EFWFMPQNFOU  *OTUBMMUVSCPJPTQBDLBHF IUUQTHJUIVCDPNIPUXJSFEUVSCPJPT   .BLF"EBQUJPOT
  4. 5VSCP/BUJWF  $SFBUFJ04"QQ // SceneDelegate.swift import UIKit import Turbo import

    WebKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { }
  5. 5VSCP/BUJWF  $SFBUFJ04"QQ "EEJOHCBTJDWBSJBCMFT // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { var window: UIWindow? private let navigationController = UINavigationController() }
  6. 5VSCP/BUJWF  $SFBUFJ04"QQ "EEJOHBTFTTJPO 5IFPCKFDUUIBUWJTJUTTDSFFOT QPQTUIFNPOUPUIFIJFSBSDIZ  BEBQUFSUIBUIPPLTJOUPUIF+4GPS5VSCPKT // SceneDelegate.swift

    class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Our variables private lazy var session: Session = { let configuration = WKWebViewConfiguration() let session = Session(webViewConfiguration: configuration) session.delegate = self return session }() }
  7. 5VSCP/BUJWF  $SFBUFJ04"QQ -BVODIJOHUIFBQQ // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables // Our session variable func scene(_ scene: UIScene, willConnectTo session:UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } self.window = UIWindow(windowScene: windowScene) self.window?.rootViewController = navigationController self.window?.makeKeyAndVisible() visit() } }
  8. 5VSCP/BUJWF  $SFBUFJ04"QQ &OIBODFUIF7JTJUBCMF7JFX$POUSPMMFS 7JTJUUIFXFCBQQPOJ04BQQMBVODI // SceneDelegate.swift class SceneDelegate: UIResponder,

    UIWindowSceneDelegate { // Our variables // Our session variable // scene function – to launch the app private func visit() { let url = URL(string: "http://localhost:3000")! let controller = VisitableViewController(url: url) session.visit(controller, action: .advance) // push contoller (aka screen) onto the stack navigationController.pushViewController(controller, animated: true) } }
  9. 5VSCP/BUJWF  $SFBUFJ04"QQ 4DFOF%FMFHBUFDPOGPSNTUPUIF4FTTJPO%FMFHBUFQSPUPDPM -FUVTLOPXXIFOBMJOLJTDMJDLFE // SceneDelegate.swift class SceneDelegate: UIResponder,

    UIWindowSceneDelegate { // Some code } extension SceneDelegate: SessionDelegate { func session(_ session: Session, didProposeVisit proposal: VisitProposal) { let controller = VisitableViewController(url: proposal.url) session.visit(controller, options: proposal.options navigationController.pushViewController(controller, animated: true) } }
  10. 5VSCP/BUJWF  $SFBUFJ04"QQ 4DFOF%FMFHBUFDPOGPSNTUPUIF4FTTJPO%FMFHBUFQSPUPDPM -FUVTLOPXXIFOBMJOLJTDMJDLFE // SceneDelegate.swift class SceneDelegate: UIResponder,

    UIWindowSceneDelegate { // Some code } extension SceneDelegate: SessionDelegate { // Our code from 5 seconds ago func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) { // Some code } func sessionWebViewProcessDidTerminate(_ session: Session) { // Some code } }
  11. 5VSCP/BUJWF  $SFBUFJ04"QQ 5IF4DFOF%FMFHBUF // SceneDelegate.swift // imports // class

    SceneDelegate // Our variables // Our session variable // scene function – to launch the app // visit function – to connect native to the web // extension SceneDelegate: SessionDelegate // session function to visit links // session function for failed requests // sessionWebViewProcessDidTerminate function OBUJWFBQQà
  12. 5VSCP/BUJWF  $SFBUFJ04"QQ J04"QQ // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables private lazy var session: Session = { let configuration = WKWebViewConfiguration() let session = Session(webViewConfiguration: configuration) session.delegate = self return session }() }
  13. 5VSCP/BUJWF  $SFBUFJ04"QQ J04"QQ // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables private lazy var session: Session = { let configuration = WKWebViewConfiguration() configuration.applicationNameForUserAgent = "Turbo Native iOS" let session = Session(webViewConfiguration: configuration) session.delegate = self return session }() }
  14. 5VSCP/BUJWF  $SFBUFJ04"QQ 3BJMT"QQ # application.css .turbo-native-hidden { display: block

    !important; } .turbo-native-shown { display: none !important; }
  15. 5VSCP/BUJWF  $SFBUFJ04"QQ 3BJMT"QQ # native.css .turbo-native-hidden { display: none

    !important; } .turbo-native-shown { display: block !important; } # application.html.erb <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo- track': 'reload’ %> <%= stylesheet_link_tag 'native', media: 'all', 'data-turbo-track': 'reload' if turbo_native_app? %>
  16. 5VSCP/BUJWF  $SFBUFJ04"QQ 3BJMT"QQ # shared/_navbar.html.erb <div class="mt-11"> <div class="turbo-native-hidden">

    <%= render 'shared/web_navbar' %> </div> <div class="turbo-native-shown"> <%= render 'shared/native_navbar' %> </div> </div> OBUJWFXFCBQQà
  17.  "EBQU3BJMTBQQ  QJOTUSBEBQBDLBHF  8SJUFTUJNVMVTDPOUSPMMFS  $POOFDUTUJNVMVTDPOUSPMMFSIJEFUIFFMFNFOU  "EBQUJ04BQQ

     *OTUBMMTUSBEBJPT IUUQTHJUIVCDPNIPUXJSFETUSBEBJPT   "EBQU8,8FC7JFX$POGJHVSBUJPO  $SFBUF5VSCP8FC7JFX$POUSPMMFS  "EBQU4DFOF%FMFHBUF  $SFBUF$PNQPOFOU 4USBEB OBUJWFBQQà
  18. 4USBEB  3BJMT"QQ 1JOTUSBEBQBDLBHF # config/importmap.rb pin "@hotwired/stimulus", to: "@hotwired--stimulus.js"

    # @3.2.2 pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "@hotwired/strada", to: "@hotwired--strada.js" # @1.0.0 # by running `./bin/importmap pin @hotwired/stimulus @hotwired/strada
  19. 4USBEB  3BJMT"QQ 8SJUFTUJNVMVTDPOUSPMMFS # javascript/controllers/bridge/form_controller.js import { BridgeComponent, BridgeElement

    } from "@hotwired/strada" export default class extends BridgeComponent { static component = "form" static targets = ["submit"] connect() { super.connect() this.#notifyBridgeOfConnect() } #notifyBridgeOfConnect() { const submitButton = new BridgeElement(this.submitTarget) const submitTitle = submitButton.title this.send("connect", {submitTitle}, () => { this.submitTarget.click() }) } }
  20. 4USBEB  J04"QQ "EBQU8,8FC7JFX$POGJHVSBUJPO // WKWebViewConfiguration+App.swift import Foundation import WebKit

    import Strada enum WebViewPool { static var shared = WKProcessPool() } // More code soon
  21. 4USBEB  J04"QQ "EBQU8,8FC7JFX$POGJHVSBUJPO // WKWebViewConfiguration+App.swift // Code from 5

    seconds ago extension WKWebViewConfiguration { static var appConfiguration: WKWebViewConfiguration { let stradaComponents = [FormComponent.self] let stradaSubstring = Strada.userAgentSubstring(for: stradaComponents) let userAgent = "Turbo Native iOS \(stradaSubstring)" let configuration = WKWebViewConfiguration() configuration.processPool = WebViewPool.shared configuration.applicationNameForUserAgent = userAgent configuration.defaultWebpagePreferences?.preferredContentMode = .mobile return configuration } }
  22. 4USBEB  J04"QQ $SFBUF5VSCP8FC7JFX$POUSPMMFS // TurboWebViewController.swift import UIKit import Turbo

    import Strada import WebKit final class TurboWebViewController: VisitableViewController, BridgeDestination { private lazy var bridgeDelegate: BridgeDelegate = { BridgeDelegate(location: visitableURL.absoluteString, destination: self, componentTypes: [FormComponent.self]) }() // Lots of more code for lifecycle & visitable }
  23. 4USBEB  J04"QQ "EBQU4DFOF%FMFHBUF // SceneDelegate.swift import UIKit import Turbo

    import Strada import WebKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { // some variables // use the new config! let webView = WKWebView(frame: .zero, configuration: .appConfiguration) // More changes coming up
  24. 4USBEB  J04"QQ "EBQU4DFOF%FMFHBUF // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables (incl. webView) private lazy var session: Session = { let configuration = WKWebViewConfiguration() let session = Session(webViewConfiguration: configuration) session.delegate = self return session }() }
  25. 4USBEB  J04"QQ "EBQU4DFOF%FMFHBUF // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables (incl. webView) private lazy var session: Session = { let session = Session(webView: webView) Bridge.initialize(webView) session.delegate = self return session }() }
  26. 4USBEB  J04"QQ "EBQU4DFOF%FMFHBUF // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables (incl. WebView) // Our new session variable // scene function – to launch the app private func visit() { let url = URL(string: "http://localhost:3000")! let controller = VisitableViewController(url: url) session.visit(controller, action: .advance) navigationController.pushViewController(controller, animated: true) } }
  27. 4USBEB  J04"QQ "EBQU4DFOF%FMFHBUF // SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate

    { // Our variables (incl. WebView) // Our new session variable // scene function – to launch the app private func visit() { let url = URL(string: "http://localhost:3000")! let controller = TurboWebViewController(url: url) session.visit(controller, action: .advance) navigationController.pushViewController(controller, animated: true) } }
  28. 4USBEB  J04"QQ $SFBUF$PNQPOFOU // Components/FormComponent.swift import Foundation import Strada

    import UIKit final class FormComponent: BridgeComponent { // name of the component as in the Stimulus controller override class var name: String { "form" } private weak var button: UIBarButtonItem? // who is in charge of rendering this screen right now private var viewController: UIViewController? { delegate.destination as? UIViewController } }
  29. 4USBEB  J04"QQ $SFBUF$PNQPOFOU // Components/FormComponent.swift final class FormComponent: BridgeComponent

    { // our variables override func onReceive(message: Message) { guard let viewController = viewController else { return } guard let data: MessageData = message.data() else { return } // click on the element that this component was wired up to let action = UIAction(title: data.submitTitle) { [unowned self] _ in self.reply(to: "connect") } // define native element let button = UIBarButtonItem(primaryAction: action) // use native button viewController.navigationItem.rightBarButtonItem = button self.button = button } }
  30. 4USBEB  J04"QQ $SFBUF$PNQPOFOU // Components/FormComponent.swift final class FormComponent: BridgeComponent

    { // our variables // func onReceive } // data that matches to what we send as data within stimulus controller private extension FormComponent { struct MessageData: Decodable { let submitTitle: String }
  31. 4USBEB 8FCWT/BUJWF"QQ Web Button Response Web App Native App Native

    Button Native Button Web Button Response clicks instructs actually clicks shows user clicks