Turbo Laravel

10. Our First Bridge Component

Rendering the create chirps form inline right on the homepage isn't the best UX for mobile. Instead, it would be better to display the form as a native modal screen. Let's implement that, but first, let's hide the entire create chirps form on Hotwire Native.

Hiding the Elements for Hotwire Native only

We could technically prevent the entire section from even rendering on requests made by Hotwire Native clients using the @unlessturbonative Blade directives, something like this:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Chirps') }}
        </h2>
    </x-slot>

    <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
        <!-- Add this: -->
        @unlessturbonative
        <x-turbo::frame id="create_chirp" src="{{ route('chirps.create') }}">
            <div class="relative flex items-center justify-center py-10 px-4 rounded-lg border border-dotted border-gray-300">
                <a class="text-gray-700" href="{{ route('chirps.create') }}">
                    Add a new Chirp
                    <span class="absolute inset-0"></span>
                </a>
            </div>
        </x-turbo::frame>
        @endunlessturbonative

        <div id="chirps" class="mt-6 bg-white shadow-sm rounded-lg divide-y">
            @each('chirps._chirp', $chirps, 'chirp')
        </div>
    </div>
</x-app-layout>

This would work, but that makes this page harder to cache. Not a problem now, but I prefer doing things like that on the client-side with CSS and a bit of JS.

Since we have configured our WebView to use a custom User-Agent header, we can actually detect when our webapp is running inside a Hotwire Native client by checking that. Let's first add a helper to check the platform by creating a resources/js/helpers/platform.js file with the following contents:

const { userAgent } = window.navigator;

export const isIos = /iPhone|iPad/.test(userAgent)
export const isAndroid = /Android/.test(userAgent)
export const isMobile = isIos || isAndroid

export const isIosApp = /Hotwire Native iOS/.test(userAgent)
export const isAndroidApp = /Hotwire Native Android/.test(userAgent)
export const isMobileApp = isIosApp || isAndroidApp

Now, let's update our resources/js/app.js file to add a turbo-native class to out HTML document:

import './bootstrap';
import './elements/turbo-echo-stream-tag';
import './libs';
import '@github/time-elements';

// Add this:
import { isMobileApp } from './helpers/platform';

if (isMobileApp) {
    document.documentElement.classList.add('turbo-native');
}

This will ensure that when our web app runs inside a Hotwire Native client, a .turbo-native class will be added to the <html> element in our page, but we're not doing anything yet with it. Let's create a custom Tailwind CSS modifier that will allow us to make things behave differently when the .turbo-native class is present in the document.

To do that, open the tailwind.config.js file in the root of your Laravel app and make the following changes:

const defaultTheme = require('tailwindcss/defaultTheme');
const plugin = require('tailwindcss/plugin'); // Add this

/** @type {import('tailwindcss').Config} */
module.exports = {
    // ...

    plugins: [
        require("@tailwindcss/forms"),
        // Add this:
        plugin(function ({ addVariant }) {
            return addVariant('turbo-native', ['&.turbo-native', '.turbo-native &']);
        }),
    ],
};

With that, we can use the new modifier like any other default modifier in Tailwind. Let's use it to hide the create chirps form on the index page for Hotwire Native clients. Open the resources/views/chirps/index.blade.php and make the following changes:

<x-app-layout>
    <!-- ... -->

    <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
        <!-- Update the Turbo Frame: -->
        <x-turbo::frame id="create_chirp" src="{{ route('chirps.create') }}" class="turbo-native:hidden" loading="lazy">
            <div class="relative flex items-center justify-center py-10 px-4 rounded-lg border border-dotted border-gray-300">
                <a class="text-gray-700" href="{{ route('chirps.create') }}">
                    Add a new Chirp
                    <span class="absolute inset-0"></span>
                </a>
            </div>
        </x-turbo::frame>

        <div id="chirps" class="mt-6 bg-white shadow-sm rounded-lg divide-y">
            @each('chirps._chirp', $chirps, 'chirp')
        </div>
    </div>
</x-app-layout>

Hiding the Create Chirps form

Let's also tweak our index page a bit to remove some padding and unnecessary margins:

<x-app-layout>
    <!-- ... -->

    <!-- Update this element: -->
    <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8 turbo-native:p-0">
        <x-turbo::frame id="create_chirp" src="{{ route('chirps.create') }}" class="turbo-native:hidden" loading="lazy">
            <!-- ... -->
        </x-turbo::frame>

        <!-- Update this element: -->
        <div id="chirps" class="mt-6 bg-white shadow-sm rounded-lg divide-y turbo-native:mt-0">
            @each('chirps._chirp', $chirps, 'chirp')
        </div>
    </div>
</x-app-layout>

Let's also hide the web nav bar for Hotwire Native users, our navigation should be fully native on the mobile clients anyways. To do that, change the resources/views/layouts/navigation.blade.php file:

<!-- Update the root nav element: -->
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100 turbo-native:hidden">
    <!-- ... -->
</nav>

Let's also hide the header section in the resources/views/layouts/app.blade.php layout file:

<!-- Update the Header element: -->
<header class="bg-white shadow turbo-native:hidden">
    <!-- ... -->
</header>

And our page should look like this:

Hotwire Native UI Tweaks

Adding the Floating Action Button

Now that the create chirps form is hidden, we need to allow our users to somehow navigate to the create chirps form. Let's create our custom ChirpsHomeFragment that will be specific to the chirps.index route.

Create a new Kotlin class inside the features.web package and call it ChirpsHomeFragment:

package com.example.turbochirpernative.features.web

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.turbochirpernative.R
import dev.hotwire.turbo.nav.TurboNavGraphDestination

@TurboNavGraphDestination(uri = "turbo://fragment/chirps/index")
class ChirpsHomeFragment: WebFragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_chirps_home, container, false)
    }
}

Notice that we're extending the WebFragment class so we need to make it open:

package com.example.turbochirpernative.features.web

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.example.turbochirpernative.R
import dev.hotwire.turbo.fragments.TurboWebFragment
import dev.hotwire.turbo.nav.TurboNavGraphDestination

@TurboNavGraphDestination(uri = "turbo://fragment/web")
open class WebFragment: TurboWebFragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_web, container, false)
    }

    override fun onVisitCompleted(location: String, completedOffline: Boolean) {
        super.onVisitCompleted(location, completedOffline)

        val script = "window.NativeBridge.start();"
        session.webView.evaluateJavascript(script, null)
    }
}

Also, we're rendering a different layout file, so we need to create it. Add a new layout file by right-clicking on the res/layout folder and choosing the "New -> Layout Resource File" option in the menu, add the following contents to it:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?colorPrimary"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            app:titleTextColor="?colorOnPrimary"
            android:layout_height="wrap_content" />

    </com.google.android.material.appbar.AppBarLayout>

    <include
        layout="@layout/turbo_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/app_bar" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/floatingActionButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:clickable="true"
        android:focusable="true"
        android:tint="@color/white"
        app:tint="@color/white"
        app:backgroundTint="@color/indigo_500"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:srcCompat="@android:drawable/ic_input_add"
        android:contentDescription="New Chirp"
    />

</androidx.constraintlayout.widget.ConstraintLayout>

Now, let's register our new fragment in the MainSessionNavHostFragment:


// ... class MainSessionNavHostFragment : TurboSessionNavHostFragment() { override val registeredFragments: List<KClass<out Fragment>> get() = listOf( WebFragment::class, LoginFragment::class, ChirpsHomeFragment::class, // Add this ) // ... }

Now, let's configure our route path in the assets/json/configuration.json file to open that fragment whenever we visit the chirps.index route:

{
  "settings": {
    "screenshots_enabled": true
  },
  "rules": [
    {
      "patterns": [
        ".*"
      ],
      "properties": {
        "context": "default",
        "uri": "turbo://fragment/web",
        "pull_to_refresh_enabled": true
      }
    },
    {
      "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
      }
    }
  ]
}

Notice that the URI matches what we defined in the ChirpsHomeFragment: turbo://fragment/chirps/index.

At this point, our app looks like this:

With The FAB

But our button doesn't work yet. Let's tell Hotwire Native to make a visit to the chirps/create route. First, add the new constant to the util.Constants file:

package com.example.turbochirpernative.util

const val BASE_URL = "http://10.0.2.2"
const val CHIRPS_HOME_URL = "$BASE_URL/chirps"
const val CHIRPS_CREATE_URL = "$CHIRPS_HOME_URL/create" // Add this

const val API_BASE_URL = "$BASE_URL/api"
const val API_CSRF_COOKIES_URL = "$BASE_URL/sanctum/csrf-cookie"
const val API_LOGIN_URL = "$API_BASE_URL/login"

Now, change the ChirpsHomeFragment to setup the click handler on that fab:

@TurboNavGraphDestination(uri = "turbo://fragment/chirps/index")
class ChirpsHomeFragment: WebFragment() {
    // ...

    // Add this:
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setupFab()
    }

    // Add this:
    private fun setupFab() {
        view?.findViewById<FloatingActionButton>(R.id.createChirpsFab)?.setOnClickListener {
            navigate(CHIRPS_CREATE_URL)
        }
    }
}

Now, let's click on it and you should be redirected to the create chirps form! How cool is that?!

Create Chirps Form

If you try creating a chirp, however, you should see some interesting behavior...

Wrong behavior after creating chirps

That's not good. That's because we're returning Turbo Streams on the ChirpController@store action. Let's change it so it doesn't do that when the request was done via a Hotwire Native client. We want the redirect there. Head to the app/Http/Controllers/ChirpController.php file and change the store action like so:

<?php

namespace App\Http\Controllers;

use App\Models\Chirp;
use Illuminate\Http\Request;

class ChirpController extends Controller
{
    // ...

    public function store(Request $request)
    {
        $validated = $request->validate([
            'message' => ['required', 'string', 'max:255'],
        ]);

        $chirp = $request->user()->chirps()->create($validated);

        // Check if the request came from a Hotwire Native client...
        if ($request->wantsTurboStream() && ! $request->wasFromHotwireNative()) {
            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.'));
    }

    // ...
}

This should redirect us to the home page after creating a chirp and the new Chirp should appear there! Cool.

We have lost the flash message, but we'll handle that soon. One thing is bothering me: we're showing the "Turbo Chirper Native" title on every screen. I don't like that. Instead, I want each screen to customize the title. Well, it turns out it already does that based on the title of the page we're visiting. Let's change our app.blade.php layout file to accept a $title prop:

<title>{{ $title ?? config('app.name', 'Laravel') }}</title>

Now, let's register the prop in the AppLayout component in app/View/Components:

<?php

namespace App\View\Components;

use Illuminate\View\Component;

class AppLayout extends Component
{
    public function __construct(public ?string $title = null)
    {
    }

    public function render()
    {
        return view('layouts.app');
    }
}

Then, in the resources/views/chirps/create.blade.php file, set the :title prop in the <x-app-layout> component:

<!-- Add the title prop: -->
<x-app-layout :title="__('Create Chirp')">
    <!-- ... -->
</x-app-layout>

And with that, our create chirps page should have the "Create Chirp" title:

Create Chirp Screen with Title