4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SaladUIとElixirDesktopでモバイルアプリを作成する

Last updated at Posted at 2024-09-04

はじめに

本記事はElixirでモバイルアプリを作成できるようにするライブラリElixirDesktopと
tailwindのUIコンポーネントであるShadcnUIをベースにしたLiveViewコンポーネント群のSaladUIを使用して実際にモバイルアプリを作成する方法を紹介する記事になります

今回はデスクトップモードで開発を行います

プロジェクト作成

まずPhoenixPJを作成します
今回はアプリ単体で動くようにDBにSqlite3を指定します

mix phx.new pocket_book --database sqlite3

desktop_setupとsalad_uiを追加

mix.exs
defp deps do
  [
    ...
    {:bandit, "~> 1.2"}, 
    {:desktop_setup, github: "thehaigo/desktop_setup", only: :dev}, # 追加
    {:salad_ui, "~> 0.4.2"} # 追加
  ]
end

追加したら以下のコマンドでデスクトップアプリ化を行います

$ mix deps.get
$ mix desktop.install

完了したら以下のコマンドでアプリを起動します、画像のように表示されたら成功です

$ iex -S mix

02_04_start_desktop_app.png

SaladUIセットアップ

公式サイトに沿って SaladUIのセットアップを行います

スクリーンショット 2024-09-04 12.57.20.png

本家のページのテーマを選択し、customを推して色を選択します
そのあとcopy codeをクリックするとクリップボードに色データが入っていますのでapp.cssに追加します

今回は紫を選択しました

assets/css/app.css
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* This file is for your main application CSS */

# ここから追加
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 224 71.4% 4.1%;
    --card: 0 0% 100%;
    --card-foreground: 224 71.4% 4.1%;
    --popover: 0 0% 100%;
    --popover-foreground: 224 71.4% 4.1%;
    --primary: 262.1 83.3% 57.8%;
    --primary-foreground: 210 20% 98%;
    --secondary: 220 14.3% 95.9%;
    --secondary-foreground: 220.9 39.3% 11%;
    --muted: 220 14.3% 95.9%;
    --muted-foreground: 220 8.9% 46.1%;
    --accent: 220 14.3% 95.9%;
    --accent-foreground: 220.9 39.3% 11%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 20% 98%;
    --border: 220 13% 91%;
    --input: 220 13% 91%;
    --ring: 262.1 83.3% 57.8%;
    --radius: 0.5rem;
    --chart-1: 12 76% 61%;
    --chart-2: 173 58% 39%;
    --chart-3: 197 37% 24%;
    --chart-4: 43 74% 66%;
    --chart-5: 27 87% 67%;
  }

  .dark {
    --background: 224 71.4% 4.1%;
    --foreground: 210 20% 98%;
    --card: 224 71.4% 4.1%;
    --card-foreground: 210 20% 98%;
    --popover: 224 71.4% 4.1%;
    --popover-foreground: 210 20% 98%;
    --primary: 263.4 70% 50.4%;
    --primary-foreground: 210 20% 98%;
    --secondary: 215 27.9% 16.9%;
    --secondary-foreground: 210 20% 98%;
    --muted: 215 27.9% 16.9%;
    --muted-foreground: 217.9 10.6% 64.9%;
    --accent: 215 27.9% 16.9%;
    --accent-foreground: 210 20% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 20% 98%;
    --border: 215 27.9% 16.9%;
    --input: 215 27.9% 16.9%;
    --ring: 263.4 70% 50.4%;
    --chart-1: 220 70% 50%;
    --chart-2: 160 60% 45%;
    --chart-3: 30 80% 55%;
    --chart-4: 280 65% 60%;
    --chart-5: 340 75% 55%;
  }
}
# ここまで追加

次に色情報のjsonを作成します

assets/tailwind.colors.json
{
  "accent": {
    "DEFAULT": "hsl(var(--accent))",
    "foreground": "hsl(var(--accent-foreground))"
  },
  "background": "hsl(var(--background))",
  "border": "hsl(var(--border))",
  "card": {
    "DEFAULT": "hsl(var(--card))",
    "foreground": "hsl(var(--card-foreground))"
  },
  "destructive": {
    "DEFAULT": "hsl(var(--destructive))",
    "foreground": "hsl(var(--destructive-foreground))"
  },
  "foreground": "hsl(var(--foreground))",
  "input": "hsl(var(--input))",
  "muted": {
    "DEFAULT": "hsl(var(--muted))",
    "foreground": "hsl(var(--muted-foreground))"
  },
  "popover": {
    "DEFAULT": "hsl(var(--popover))",
    "foreground": "hsl(var(--popover-foreground))"
  },
  "primary": {
    "DEFAULT": "hsl(var(--primary))",
    "foreground": "hsl(var(--primary-foreground))"
  },
  "ring": "hsl(var(--ring))",
  "secondary": {
    "DEFAULT": "hsl(var(--secondary))",
    "foreground": "hsl(var(--secondary-foreground))"
  }
}

色情報のファイルを作成したら、tailwind.confg.jsを以下のように変更します

assets/tailwind.config.js
module.exports = {
  content: [
    "./js/**/*.js",
+   "../deps/salad_ui/lib/**/*.ex",
    "../lib/pocket_book_web.ex",
    "../lib/pocket_book_web/**/*.*ex",
  ],
    extend: {
-     colors: {
-       brand: "#FD4F00",
-     }
+     colors: require("./tailwind.colors.json")
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
+   require("@tailwindcss/typography"),
+   require("tailwindcss-animate"),
    ...
  ],
};

変更したらアニメーション用のライブラリを追加します

$ cd assets
$ yarn add -D tailwindcss-animate

最後に色情報のファイルのパスを設定して完了です

config/config.exs
config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json")

CoreComponentのコンポーネントをSaladUIと差し替え

CoreComponentとSaladUIでコンポーネント名が衝突するものがあるので、
exceptオプションで読み込みから除外して、差し替えるSaladUIのコンポーネントを読み込みます
これで、アプリ全体を通してSaladUIの.buttonを使うようになります

lib/pocket_book_web.ex
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
-     import PocketBookWeb.CoreComponents
+     import PocketBookWeb.CoreComponents, except: [button: 1, label: 1]
+     import SaladUI.Button
+     import SaladUI.Label
    end
  end

CRUD機能の実装

次に以下のコマンドでCRUD画面を作成します

$ mix phx.gen.live Pages Page pages title:string body:text

Migrationファイルのコピー

ElixirDesktopのアプリの場合、mix ecto.migrationで端末内等のSqliteファイルにマイグレーションを実行できないため、アプリ起動時にマイグレーションを実行するようになっています

アプリ起動時のマイグレーションを行えるように以下の処理を行います

  • priv/repo/migrationsフォルダをpocket_book/にコピーします
  • マイグレーションファイルを[timestamp]_create_pages.exs から [timestamp]_create_pages.exに変更します
  • マイグレーションファイルのモジュール名を以下のように変更
lib/pocket_book/migrations/20240904041336_create_pages.ex
- defmodule PocketBook.Repo.Migrations.CreatePages do
+ defmodule PocketBook.Migrations.CreatePages do
  use Ecto.Migration

  def change do
    create table(:pages) do
      add :title, :string
      add :body, :text

      timestamps(type: :utc_datetime)
    end
  end
end
  • migrationスクリプトがrepo.exにあるので以下のように追加する
lib/pocket_book/repo.ex
defmodule PocketBook.Repo do
  def migration() do
+  Ecto.Migrator.up(PocketBook.Repo, 20240904041336, PocketBook.Migrations.CreatePages)  
  end
end

これで起動時にマイグレーションがまだの場合実行してくれます

起動時の初期ページの設定

通常のままだとトップページしか開けないため初期ページを以下のように変更します

lib/pocket_book.ex
defmodule PocketBook do
  use Application
  
  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    ...
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: PocketBookWindow,
          title: "pocket_book",
          size: {400, 800},
-         url: "http://localhost:#{port}"          
+         url: "http://localhost:#{port}/pages"
        ]
      })
  end
end

デザイン修正

縦方向のパディングが多いので減らします

lib/pocket_book_web/components/layouts/app.html.heex
- <main class="px-4 py-40 sm:px-6 lg:px-8">
+ <main class="px-4 py-4 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

SaladUIでCardレイアウト化

SaladUIの Cardコンポーネントでデフォルトのテーブル表示をカードレイアウトに変更します

CardとBadgeを読み込みます

lib/pocket_book_web/live/page_live/index.ex
defmodule PocketBookWeb.PageLive.Index do
  use PocketBookWeb, :live_view

  alias PocketBook.Pages
  alias PocketBook.Pages.Page

+ import SaladUI.Card
+ import SaladUI.Badge

  ...
end

<.table></.table>を削除して以下のように追加します

lib/pocket_book_web/live/page_live/index.html.heex
<.header>
  Listing Pages
  <:actions>
    <.link patch={~p"/pages/new"}>
      <.button>New Page</.button>
    </.link>
  </:actions>
</.header>

<div
  id="pages"
  class="mt-4 flex flex-wrap gap-1"
  phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.pages) && "stream"}
>
  <%= for {id, page} <- @streams.pages do %>
    <.link navigate={~p"/pages/#{page}"}>
      <.card id={id} class="w-[42vw] ml-2 mb-2">
        <.card_header>
          <.card_title><%= page.title %></.card_title>
        </.card_header>
        <.card_content class="truncate">
          <%= page.body %>
        </.card_content>
        <.card_footer class="flex justify-end gap-x-1 -mr-4">
          <.link patch={~p"/pages/#{page}/edit"}>
            <.badge variant="outline">Edit</.badge>
          </.link>
          <.link patch={~p"/pages/#{page}/delete"}>
            <.badge variant="destructive">Delete</.badge>
          </.link>
        </.card_footer>
      </.card>
    </.link>
  <% end %>
</div>
...

スクリーンショット 2024-09-04 15.15.03.png

削除確認モーダル

confimやalertダイアログはElixirDesktop等のWebViewのアプリで表示できないので、
Dialogコンポーネントを使用して実装します

最初にルートを追加

lib/pocket_book_web/router.ex
  scope "/", PocketBookWeb do
    pipe_through :browser

    get "/", PageController, :home

    live "/pages", PageLive.Index, :index
    live "/pages/new", PageLive.Index, :new
    live "/pages/:id/edit", PageLive.Index, :edit
+   live "/pages/:id/delete", PageLive.Index, :delete


    live "/pages/:id", PageLive.Show, :show
    live "/pages/:id/show/edit", PageLive.Show, :edit
  end

次にDialogを読み込んで、パラメータのパターンマッチの追加と削除イベントを修正します

lib/pocket_book_web/live/page_live/index.ex
defmodule PocketBookWeb.PageLive.Index do
  use PocketBookWeb, :live_view

  alias PocketBook.Pages
  alias PocketBook.Pages.Page

  import SaladUI.Card
+ import SaladUI.Dialog 

  ...

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Pages")
    |> assign(:page, nil)
  end

+  defp apply_action(socket, :delete, %{"id" => id}) do
+   socket
+   |> assign(:page_title, "Delete Page")
+   |> assign(:page, Pages.get_page!(id))
+  end


  @impl true
  def handle_info({PocketBookWeb.PageLive.FormComponent, {:saved, page}}, socket) do
    {:noreply, stream_insert(socket, :pages, page)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    page = Pages.get_page!(id)
    {:ok, _} = Pages.delete_page(page)

-   {:noreply, stream_delete(socket, :pages, page)}
+    {
+      :noreply,
+      socket
+      # flashを表示
+      |> put_flash(:info, "Page deleted successfully")
+      # モーダルを閉じるように変更
+      |> push_navigate(to: ~p"/pages")
+      |> stream_delete(:pages, page)
+    }
  end
end

ついでにフォームのmodalをdialogに差し替えて、その下に削除確認ダイアログを追加します

lib/pocket_book_web/live/page_live/index.html.heex
- <.modal
+ <.dialog 
  :if={@live_action in [:new, :edit]}
  id="page-modal"
  show
+ class="w-[90vw]"
  on_cancel={JS.patch(~p"/pages")}
>
  <.live_component
    module={PocketBookWeb.PageLive.FormComponent}
    id={@page.id || :new}
    title={@page_title}
    action={@live_action}
    page={@page}
    patch={~p"/pages"}
  />
- </.modal>
+ </.dialog>

<.dialog
  :if={@live_action in [:delete]}
  id="delete-modal"
  class="w-[90vw]"
  show
  on_cancel={JS.patch(~p"/pages")}
>
  <.dialog_header>
    <.dialog_title>Delete Page</.dialog_title>
  </.dialog_header>
  <div class="text-center">
    Are you sure?
  </div>
  <.dialog_footer>
    <div class="flex flex-col gap-y-4 p-2">
      <.button variant="destructive" phx-click="delete" phx-value-id={@page.id}>delete</.button>
      <.button variant="outline" phx-click={JS.patch(~p"/pages")}>cancel</.button>
    </div>
  </.dialog_footer>
</.dialog>

デフォルトのモーダルではなく、ダイアログが表示されるようになりました

スクリーンショット 2024-09-04 14.51.18.png

削除確認ダイアログも問題なく表示できています

スクリーンショット 2024-09-04 14.51.25.png

最後に

SaladUIを使うことでモバイルらしいUIを簡単に構築できるようになりました
他にも色々コンポーネントがあるので是非試してみてください、本記事は以上になりますありがとうございました

参考ページ

https://qiita.com/the_haigo/items/f419bda951bb60145556
https://qiita.com/RyoWakabayashi/items/c3dc2ec8bb67adb6dc3e
https://github.com/elixir-desktop/desktop
https://salad-storybook.fly.dev/welcome
https://ui.shadcn.com/

4
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?