11. Editing in a Modal
- Introduction
- Dropdowns in a BottomSheet modal
- Telling the Native App to Show The Options
- Using a Native Modal Screen for Forms
- Testing It Out
Introduction
Right now our editing flow is not very mobile-friendly. Instead of showing the edit form inline in the list, we could show it as a native modal screen instead. But before we introduce the new native screen, let's first ensure our web dropdown appears as a real native bottom sheet list of options. That will serve as an example of how we can bridge the web and mobile native Worlds with a little bit of JavaScript. This approach was based on how the Hey app works (at least on the pieces I could spot from inspecting the page source).
Dropdowns in a BottomSheet modal
Let's first create our BottomSheet. This one won't be driven by a navigation, though. We're going implement a web->native bridge that we can trigger when the app is running inside a Turbo Native client. For now, let's create a new XML view. Head to the sidebar, under "res/layout", right-click on it an choose "New -> Layout Resource File". Call it "popup_menu.xml" and add this content:
<?xml version="1.0" encoding="utf-8"?><androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content"> <LinearLayout android:id="@+id/popupMenuWrapper" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="2dp" android:orientation="vertical"> </LinearLayout> </androidx.cardview.widget.CardView>
This will be programatically added, so what's really important here is the android:id
property. Since we're going to trigger this native feature from the web, the Turbo Native for Android documentation says that the best location is in the TurboSessionNavHostFragment::onSessionCreated
function. Let's then tweak our MainSessionNavHostFragment
with the following change:
package com.example.turbochirpernative.main import android.webkit.JavascriptInterface import android.webkit.WebViewimport android.widget.Button import android.widget.LinearLayoutimport android.widget.Toast import androidx.appcompat.app.AppCompatActivityimport androidx.compose.ui.res.integerResourceimport androidx.fragment.app.Fragmentimport com.example.turbochirpernative.BuildConfigimport com.example.turbochirpernative.R import com.example.turbochirpernative.features.auth.LoginFragmentimport com.example.turbochirpernative.features.web.ChirpsHomeFragmentimport com.example.turbochirpernative.features.web.WebFragmentimport com.example.turbochirpernative.util.CHIRPS_HOME_URLimport com.google.android.material.bottomsheet.BottomSheetDialog import com.google.gson.Gson import dev.hotwire.turbo.config.TurboPathConfigurationimport dev.hotwire.turbo.session.TurboSessionNavHostFragmentimport kotlin.reflect.KClass class MainSessionNavHostFragment : TurboSessionNavHostFragment() {class MainSessionNavHostFragment : TurboSessionNavHostFragment(), PopupMenuDelegator {
override val sessionName = "main" override val startLocation = CHIRPS_HOME_URL override val registeredActivities: List<KClass<out AppCompatActivity>> get() = listOf() override val registeredFragments: List<KClass<out Fragment>> get() = listOf( WebFragment::class, LoginFragment::class, ChirpsHomeFragment::class, ) override val pathConfigurationLocation: TurboPathConfiguration.Location get() = TurboPathConfiguration.Location( assetFilePath = "json/configuration.json", ) override fun onSessionCreated() { super.onSessionCreated() session.webView.settings.userAgentString = customUserAgent(session.webView) if (BuildConfig.DEBUG) { session.setDebugLoggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true) } session.webView.addJavascriptInterface( JsBridge(this), "NativeBridge", ) } private fun customUserAgent(webView: WebView): String { return "Turbo Native Android ${webView.settings.userAgentString}" } override fun showPopupMenu(options: PopupMenu) { activity?.runOnUiThread { val context = requireContext() val dialog = BottomSheetDialog(context) val view = layoutInflater.inflate(R.layout.popup_menu, null)!! val menuWrapper = view.findViewById<LinearLayout>(R.id.popupMenuWrapper)!! menuWrapper.removeAllViews() options.items.forEach { item -> val button = Button(context) button.text = item.text button.setBackgroundColor(resources.getColor(R.color.white, null)) button.setOnClickListener { session.webView.evaluateJavascript( "window.dispatchEvent(new CustomEvent('popup-menu:selected', { detail: { index: " + item.index + ", text: '" + item.text + "' } }))", null ) dialog.dismiss() } menuWrapper.addView(button) } dialog.setOnCancelListener { session.webView.evaluateJavascript("window.dispatchEvent(new CustomEvent('popup-menu:canceled'))", null) } dialog.setCancelable(true) dialog.setContentView(view) dialog.show() } } override fun showToast(msg: String) { activity?.runOnUiThread { Toast .makeText(requireContext(), msg, Toast.LENGTH_SHORT) .show() } }} data class MenuItem( val text: String, val index: Int,) data class PopupMenu( val items: List<MenuItem>,) interface PopupMenuDelegator { fun showPopupMenu(options: PopupMenu); fun showToast(msg: String);} class JsBridge(private var delegator: PopupMenuDelegator) { @JavascriptInterface override fun toString(): String { return "NativeBridge" } @JavascriptInterface fun showPopup(json: String) { val gson = Gson() val options = gson.fromJson(json, PopupMenu::class.java) delegator.showPopupMenu(options) } @JavascriptInterface fun showToast(msg: String) { delegator.showToast(msg) } }
Now we have the BottomSheet Menu ready to be triggered by our webapp.
Telling the Native App to Show The Options
We're gonna create a Stimulus controller that will act when a user triggers the dropdown inside a Turbo Native client. We're also gonna use the custom User Agent to detect we're on that platform. Whenever that is the case, we'll get some metadata from the HTML and pass that to the Native app so it can build the native menu. When the user either picks one of the options or dismisses the menu, we're gonna notify the web app about it.
Let's then add the Stimulus controller. Open a terminal at the root of the webapp and run:
php artisan stimulus:make bridge/popup_menu_controller
That should take care of creating and registering the controller for us. Open that Stimulus controller and place the following content:
import { Controller } from "@hotwired/stimulus"import { isMobileApp } from "../../helpers/platform"import { BridgeElement } from "../../helpers/bridge_element" // Connects to data-controller="bridge--popup-menu"export default class extends Controller { static targets = ['option'] connect() { this.clearCallbacks() } update(event) { if (! this.enabled) return event.stopImmediatePropagation() event.preventDefault() this.notifyBridgeToDisplayMenu(event) } notifyBridgeToDisplayMenu(event) { const items = BridgeElement.makeMenuItems(this.optionTargets) this.send(items, item => { new BridgeElement(this.optionTargets[item.index]).click() }) } send(items, callback) { this.registeredCallbacks.push(callback) window.NativeBridge.showPopup(JSON.stringify({ items })) } handle(event) { let handler = this.registeredCallbacks.pop() if (! handler) return handler.call(this, event.detail) } clearCallbacks() { this.registeredCallbacks = [] } get enabled() { return isMobileApp }}
So, we're detecting if we're on a Turbo Native client using the isMobileApp
platform check (that same one we're using to add the turbo-native
CSS class to the HTML document). The update
method will be triggered by the dropdown trigger (same one that opens it), but we need to register it before the normal web trigger because we want to stop it from showing the dropdown and, instead, show the native menu. When the dropdown opens, we'll scan through all the option targets and fetch metadata from it, then register a callback in the controller's instance. When the user picks one of the options, the native client will dispatch a custom event to the window, so all instances of dropdown controllers will receive that event, but only the one with the callback will act on it. Then, it should trigger the default behavior of that option (link or button click).
When the controller scans the option targets, it builds an instance of a BridgeElement
class, which we don't have yet. Let's add one at resources/js/helpers/bridge_element.js
:
import { isAndroidApp, isMobileApp } from "./platform" export class BridgeElement { static makeMenuItems(elements){ return elements.map((element, index) => { return new BridgeElement(element).asMenuItem(index) }).filter(item => item) } constructor(element) { this.element = element } asMenuItem(index) { if (this.disabled) return return { text: this.text, index: index, } } click() { this.ensureFrameOrFormTargetsTop() // Remove the target attribute before clicking to avoid an // issue in Android WebView that prevents a target="_blank" // url from being obtained from a javascript click. if (isAndroidApp) { this.element.removeAttribute("target") } this.element.click() } ensureFrameOrFormTargetsTop() { this.ensureFrameTargetsTop() this.ensureFormTargetsTop() } ensureFrameTargetsTop() { let frame = this.element.closest('turbo-frame') if (! frame) return frame.setAttribute('target', '_top') } ensureFormTargetsTop() { let form = this.element.closest('form') if (! form) return form.setAttribute('data-turbo-frame', '_top') } get text() { return this.element.textContent.trim() } get disabled() { return ! this.enabled } get enabled() { return isMobileApp }}
Next, let's update the our dropdown
blade component to use this Stimulus controller we just created:
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) @phpswitch ($align) { case 'left': $alignmentClasses = 'origin-top-left left-0'; break; case 'top': $alignmentClasses = 'origin-top'; break; case 'right': default: $alignmentClasses = 'origin-top-right right-0'; break;} switch ($width) { case '48': $width = 'w-48'; break;}@endphp <div class="relative" data-controller="dropdown" data-controller="bridge--popup-menu dropdown" data-action=" click@window->dropdown#close turbo:load@window->dropdown#close popup-menu:canceled@window->bridge--popup-menu#clearCallbacks popup-menu:selected@window->bridge--popup-menu#handle " {{ $attributes }}> <div data-action="click->dropdown#toggle click->dropdown#stop"> <div data-action="click->bridge--popup-menu#update click->dropdown#toggle click->dropdown#stop"> {{ $trigger }} </div> <div data-dropdown-target="content" data-transition-enter="transition ease-out duration-200" data-transition-enter-start="transform opacity-0 scale-95" data-transition-enter-end="transform opacity-100 scale-100" data-transition-leave="transition ease-in duration-75" data-transition-leave-start="transform opacity-100 scale-100" data-transition-leave-end="transform opacity-0 scale-95" class="hidden absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}" > <div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}"> {{ $content }} </div> </div></div>
Now, we need to make sure the options are registered in the Stimulus controller where this Blade component is used. Let's update our _chirp
blade partial:
<x-turbo::frame :id="$chirp" class="block p-6"> <div class="flex space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-600 -scale-x-100" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> </svg> <div class="flex-1"> <div class="flex justify-between items-center">
<div> <span class="text-gray-800">{{ $chirp->user->name }}</span> <x-time-ago :date="$chirp->created_at" /> @unless ($chirp->created_at->eq($chirp->updated_at)) <small class="text-sm text-gray-600"> · edited</small> @endunless </div> @if (Auth::id() === $chirp->user->id) <x-dropdown align="right" width="48" data-bridge--popup-menu-msg-value="{{ $chirp->message }}">
<x-slot name="trigger"> <button> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor"> <path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" /> </svg> </button> </x-slot> <x-slot name="content"> <a href="{{ route('chirps.edit', $chirp) }}" class="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out"> <a href="{{ route('chirps.edit', $chirp) }}" data-bridge--popup-menu-target="option" class="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out"> Edit </a> <form action="{{ route('chirps.destroy', $chirp) }}" method="POST"> @method('DELETE') <button class="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out"> <button data-bridge--popup-menu-target="option" class="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out"> Delete </button> </form> </x-slot> </x-dropdown> @endif </div> <p class="mt-4 text-lg text-gray-900">{{ $chirp->message }}</p> </div> </div></x-turbo::frame>
This should get our dropdown appearing as a BottomSheet menu!
Our Bridge Popup Menu controller also changes the frame target to _top
, which is handy in our case as that will make a full page visit to the edit page instead of rendering the form inline!
If you try to update a chirp, however, the behavior will be similar to what it was previously on the create chirp flow. Let's fix it by letting the controller redirect to the list index of chirps instead of returning Turbo Streams. Since we're tinkering with the controller, let's also update the destroy flow so it also redirects there:
<?php
namespace App\Http\Controllers; use App\Models\Chirp;use Illuminate\Http\Request; class ChirpController extends Controller{
/** * Display a listing of the resource. * * @return \Illuminate\Http\Response */ public function index() { return view('chirps.index', [ 'chirps' => Chirp::with('user:id,name')->latest()->get(), ]); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\Response */ public function create() { return view('chirps.create'); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */ public function store(Request $request) { $validated = $request->validate([ 'message' => ['required', 'string', 'max:255'], ]); $chirp = $request->user()->chirps()->create($validated); if ($request->wantsTurboStream() && ! $request->wasFromTurboNative()) { return turbo_stream([ turbo_stream($chirp, 'prepend'), turbo_stream()->update('create_chirp', view('chirps._form')), turbo_stream()->append('notifications', view('layouts.notification', [ 'message' => __('Chirp created.'), ])), ]); } return redirect() ->route('chirps.index') ->with('status', __('Chirp created.')); } /** * Display the specified resource. * * @param \App\Models\Chirp $chirp * @return \Illuminate\Http\Response */ public function show(Chirp $chirp) { // } /** * Show the form for editing the specified resource. * * @param \App\Models\Chirp $chirp * @return \Illuminate\Http\Response */ public function edit(Chirp $chirp) { $this->authorize('update', $chirp); return view('chirps.edit', [ 'chirp' => $chirp, ]); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param \App\Models\Chirp $chirp * @return \Illuminate\Http\Response */ public function update(Request $request, Chirp $chirp) { $this->authorize('update', $chirp); $validated = $request->validate([ 'message' => ['required', 'string', 'max:255'], ]); $chirp->update($validated); if ($request->wantsTurboStream()) { if ($request->wantsTurboStream() && ! $request->wasFromTurboNative()) { return turbo_stream([ turbo_stream($chirp), turbo_stream()->append('notifications', view('layouts.notification', [ 'message' => __('Chirp updated.'), ])), ]); } return redirect() ->route('chirps.index') ->with('status', __('Chirp updated.')); }
/** * Remove the specified resource from storage. * * @param \Illuminate\Http\Request $request * @param \App\Models\Chirp $chirp * @return \Illuminate\Http\Response */ public function destroy(Request $request, Chirp $chirp) { $this->authorize('delete', $chirp); $chirp->delete(); if ($request->wantsTurboStream()) { if ($request->wantsTurboStream() && ! $request->wasFromTurboNative()) { return turbo_stream([ turbo_stream($chirp), turbo_stream()->append('notifications', view('layouts.notification', [ 'message' => __('Chirp deleted.'), ])), ]); } return redirect() ->route('chirps.index') ->with('status', __('Chirp deleted.')); }}
That should have our edit and delete flows working properly, which is cool!
Using a Native Modal Screen for Forms
Instead of showing the edit form in a new screen, I think it would be cool to introduce a native modal screen that other pages could use as well. First, add a new Fragment called WebModalFragment
under the web
package in the Android project without a layout:
package com.example.turbochirpernative.features.web import android.os.Bundleimport android.view.Viewimport com.example.turbochirpernative.util.displayBackButtonAsCloseIconimport dev.hotwire.turbo.nav.TurboNavGraphDestination @TurboNavGraphDestination(uri = "turbo://fragment/web/modal")class WebModalFragment : WebFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initToolbar() } private fun initToolbar() { toolbarForNavigation()?.displayBackButtonAsCloseIcon() }}
We need to create a new Extensions.kt file inside the util
package so we can add this displayBackButtonAsCloseIcon
method to the Toolbar:
package com.example.turbochirpernative.util import androidx.appcompat.widget.Toolbarimport androidx.core.content.ContextCompatimport com.example.turbochirpernative.R fun Toolbar.displayBackButtonAsCloseIcon() { navigationIcon = ContextCompat.getDrawable(context, R.drawable.ic_close)}
Next, add a new Vector to the drawables by right-clicking on "New -> Vector Asset" inside the "res/drawables" folder, name it ic_close
and choose the close icon.
Then, let's register the WebModalFragment
in our MainSessionNavHostFragment
:
package com.example.turbochirpernative.main
import android.webkit.JavascriptInterfaceimport android.webkit.WebViewimport android.widget.Buttonimport android.widget.LinearLayoutimport android.widget.Toastimport androidx.appcompat.app.AppCompatActivityimport androidx.fragment.app.Fragmentimport com.example.turbochirpernative.BuildConfigimport com.example.turbochirpernative.R import com.example.turbochirpernative.features.auth.LoginFragmentimport com.example.turbochirpernative.features.web.ChirpsHomeFragmentimport com.example.turbochirpernative.features.web.WebFragmentimport com.example.turbochirpernative.features.web.WebModalFragment
import com.example.turbochirpernative.util.CHIRPS_HOME_URLimport com.google.android.material.bottomsheet.BottomSheetDialogimport com.google.gson.Gsonimport dev.hotwire.turbo.config.TurboPathConfigurationimport dev.hotwire.turbo.session.TurboSessionNavHostFragmentimport kotlin.reflect.KClass class MainSessionNavHostFragment : TurboSessionNavHostFragment(), PopupMenuDelegator {
override val sessionName = "main" override val startLocation = CHIRPS_HOME_URL override val registeredActivities: List<KClass<out AppCompatActivity>> get() = listOf() override val registeredFragments: List<KClass<out Fragment>> get() = listOf( WebFragment::class, WebModalFragment::class, LoginFragment::class, ChirpsHomeFragment::class, )
override val pathConfigurationLocation: TurboPathConfiguration.Location get() = TurboPathConfiguration.Location( assetFilePath = "json/configuration.json", ) override fun onSessionCreated() { super.onSessionCreated() session.webView.settings.userAgentString = customUserAgent(session.webView) if (BuildConfig.DEBUG) { session.setDebugLoggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true) } session.webView.addJavascriptInterface( JsBridge(this), "NativeBridge", ) } private fun customUserAgent(webView: WebView): String { return "Turbo Native Android ${webView.settings.userAgentString}" } override fun showPopupMenu(options: PopupMenu) { activity?.runOnUiThread { val context = requireContext() val dialog = BottomSheetDialog(context) val view = layoutInflater.inflate(R.layout.popup_menu, null)!! val menuWrapper = view.findViewById<LinearLayout>(R.id.popupMenuWrapper)!! menuWrapper.removeAllViews() options.items.forEach { item -> val button = Button(context) button.text = item.text button.setBackgroundColor(resources.getColor(R.color.white, null)) button.setOnClickListener { session.webView.evaluateJavascript( "window.dispatchEvent(new CustomEvent('popup-menu:selected', { detail: { index: " + item.index + ", text: '" + item.text + "' } }))", null ) dialog.dismiss() } menuWrapper.addView(button) } dialog.setOnCancelListener { session.webView.evaluateJavascript("window.dispatchEvent(new CustomEvent('popup-menu:canceled'))", null) } dialog.setCancelable(true) dialog.setContentView(view) dialog.show() } } override fun showToast(msg: String) { activity?.runOnUiThread { Toast .makeText(requireContext(), msg, Toast.LENGTH_SHORT) .show() } } }
data class MenuItem( val text: String, val index: Int,) data class PopupMenu( val items: List<MenuItem>,) interface PopupMenuDelegator { fun showPopupMenu(options: PopupMenu); fun showToast(msg: String);} class JsBridge(private var delegator: PopupMenuDelegator) { @JavascriptInterface override fun toString(): String { return "NativeBridge" } @JavascriptInterface fun showPopup(json: String) { val gson = Gson() val options = gson.fromJson(json, PopupMenu::class.java) delegator.showPopupMenu(options) } @JavascriptInterface fun showToast(msg: String) { delegator.showToast(msg) }}
We need to add a new entry to our configuration so any URI ending with /edit
or /create
(our forms), will open inside of a web modal fragment instead of the default web fragment:
{ "settings": { "screenshots_enabled": true }, "rules": [ { "patterns": [ ".*" ], "properties": { "context": "default", "uri": "turbo://fragment/web", "pull_to_refresh_enabled": true } }, { "patterns": [ "/edit$", "/edit/$", "/create$", "/create/$" ], "properties": { "context": "default", "uri": "turbo://fragment/web/modal", "pull_to_refresh_enabled": false } }, { "patterns": [ "login$", "login/$" ], "properties": { "context": "default", "uri": "turbo://fragment/auth/login", "pull_to_refresh_enabled": false } }, { "patterns": [ "chirps$", "chirps/$" ], "properties": { "context": "default", "uri": "turbo://fragment/chirps/index", "pull_to_refresh_enabled": true } } ]}
Let's also hide the cancel button when inside a Turbo Native client by updating our resources/chirps/_form.blade.php
in our web app:
<form action="{{ ($chirp ?? false) ? route('chirps.update', $chirp) : route('chirps.store') }}" method="POST">
@if ($chirp ?? false) @method('PUT') @endif <textarea name="message" placeholder="What's on your mind?" class="block w-full border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 rounded-md shadow-sm" >{{ $chirp->message ?? '' }}</textarea> <x-input-error :messages="$errors->get('message')" class="mt-2" /> <div class="flex items-center justify-start space-x-2"> <x-primary-button class="mt-4"> {{ __('Chirp') }} </x-primary-button> @if ($chirp ?? false) <a href="{{ route('chirps.index') }}" class="mt-4">Cancel</a> <a href="{{ route('chirps.index') }}" class="mt-4 turbo-native:hidden">Cancel</a> @endif </div></form>
Testing It Out
Now, our app should be a bit nicer: