はじめに
本記事はElixirでモバイルアプリを作成できるようにするライブラリElixirDesktopと
tailwindのUIコンポーネントであるShadcnUIをベースにしたLiveViewコンポーネント群のSaladUIを使用して実際にモバイルアプリを作成する方法を紹介する記事になります
今回はデスクトップモードで開発を行います
プロジェクト作成
まずPhoenixPJを作成します
今回はアプリ単体で動くようにDBにSqlite3を指定します
mix phx.new pocket_book --database sqlite3
desktop_setupとsalad_uiを追加
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
SaladUIセットアップ
公式サイトに沿って SaladUIのセットアップを行います
本家のページのテーマを選択し、customを推して色を選択します
そのあとcopy codeをクリックするとクリップボードに色データが入っていますので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を作成します
{
"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を以下のように変更します
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 :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json")
CoreComponentのコンポーネントをSaladUIと差し替え
CoreComponentとSaladUIでコンポーネント名が衝突するものがあるので、
except
オプションで読み込みから除外して、差し替えるSaladUIのコンポーネントを読み込みます
これで、アプリ全体を通してSaladUIの.button
を使うようになります
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
に変更します - マイグレーションファイルのモジュール名を以下のように変更
- 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
にあるので以下のように追加する
defmodule PocketBook.Repo do
def migration() do
+ Ecto.Migrator.up(PocketBook.Repo, 20240904041336, PocketBook.Migrations.CreatePages)
end
end
これで起動時にマイグレーションがまだの場合実行してくれます
起動時の初期ページの設定
通常のままだとトップページしか開けないため初期ページを以下のように変更します
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
デザイン修正
縦方向のパディングが多いので減らします
- <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を読み込みます
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>
を削除して以下のように追加します
<.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>
...
削除確認モーダル
confimやalertダイアログはElixirDesktop等のWebViewのアプリで表示できないので、
Dialogコンポーネントを使用して実装します
最初にルートを追加
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を読み込んで、パラメータのパターンマッチの追加と削除イベントを修正します
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に差し替えて、その下に削除確認ダイアログを追加します
- <.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>
デフォルトのモーダルではなく、ダイアログが表示されるようになりました
削除確認ダイアログも問題なく表示できています
最後に
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/