SwiftUI で Image をカスタム形状にクリッピング - 5つの実装パターン

はじめに

SwiftUI で Image を表示する際、見た目を工夫したいことはよくある。丸いプロフィール画像、角丸カード、複雑なロゴ形状...こうした場合に活躍するのが clipShape modifier だ。

この記事では、clipShape を使った 5 つの実装パターン、mask との使い分け、iOS 18 での新機能、実装時の注意点まで、実践的に解説する。

clipShape とは何か

基本的な役割

clipShape は、View を指定した Shape の形状でクリッピングする modifier。Image だけでなく、あらゆる View に適用できる。iOS 13.0 以降で使用可能。

使い方はシンプル。View に .clipShape() を追加して、括弧の中に使いたい Shape を指定するだけ。

clipShape と mask:何が違うのか

同じクリッピングに見えるが、内部動作は異なる。clipShape は Shape のアウトラインを使ったシンプルなクリッピング。mask はピクセルレベルで自由に制御できる。

使い分けの指針は明確だ。形状がシンプルなら clipShape。複雑な形状やグラデーション、半透明を含むなら mask。

項目 clipShape mask
用途 Shape ベースのクリップ ピクセルレベル制御
パフォーマンス 高速(推奨) 遅い
扱える形状 Circle, Rectangle 等 任意(グラデーション等)
一般的な選択 9 割はこちら 特殊な要件時のみ

よく使う 5 つのパターン

1. 円形クリップ(プロフィール画像)

最も一般的。プロフィール画像、アバター、丸いボタン...こうした場面では Circle を使う。

import SwiftUI

struct ProfileView: View {
    var body: some View {
        Image("profileImage")
            .resizable()
            .scaledToFill()
            .frame(width: 120, height: 120)
            .clipShape(Circle())
            .overlay(
                Circle()
                    .stroke(Color.blue, lineWidth: 2)
            )
    }
}

ポイント:resizable() と scaledToFill() でアスペクト比を保ったまま枠を埋める。overlay で枠線を追加すると、さらに見栄えが良くなる。

2. 角丸四角形(カード、サムネイル)

RoundedRectangle でコーナーを丸くする。cornerRadius で丸さを調整。カード、サムネイル、バナーに使う。

struct CardView: View {
    var body: some View {
        Image("thumbnail")
            .resizable()
            .scaledToFill()
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(radius: 4)
    }
}

cornerRadius は 8-16 が標準的。大きすぎるとピルになってしまう。shadow() を組み合わせると、カードとして浮き上がって見える。

3. 楕円形(ロゴ、複雑な画像)

横長や縦長の楕円が必要な場合、Ellipse を使う。frame のアスペクト比に応じて形状が決まる。

struct LogoView: View {
    var body: some View {
        Image("logo")
            .resizable()
            .scaledToFill()
            .frame(width: 200, height: 100)
            .clipShape(Ellipse())
    }
}

4. カスタム形状(Path でオリジナル図形)

五角形、三角形、ハート...こうした複雑な形状は、Shape プロトコルに準拠したカスタムクラスで定義する。

struct StarClipView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(StarShape())
    }
}

// 五角形を定義
struct StarShape: Shape {
    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        var path = Path()

        for i in 0..<5 {
            let angle = CGFloat(i) * 0.8 * .pi - .pi / 2
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            if i == 0 {
                path.move(to: CGPoint(x: x, y: y))
            } else {
                path.addLine(to: CGPoint(x: x, y: y))
            }
        }

        path.closeSubpath()
        return path
    }
}

Shape プロトコルには path(in:) メソッドを実装する。CGPath を使ってフリーハンドで図形を描く。

5. アニメーション付きクリップ

状態に応じて形状を変える。@State で isExpanded を持って、tap で切り替える。形状を動的に変える場合は AnyShape でラップする。

struct ExpandableClipView: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Image("icon")
                .resizable()
                .scaledToFill()
                .frame(
                    width: isExpanded ? 250 : 150,
                    height: isExpanded ? 250 : 150
                )
                .clipShape(
                    isExpanded ?
                    AnyShape(RoundedRectangle(cornerRadius: 32)) :
                    AnyShape(Circle())
                )
                .onTapGesture {
                    withAnimation(.spring()) {
                        isExpanded.toggle()
                    }
                }

            Text(isExpanded ? "Collapse" : "Expand")
                .font(.caption)
                .foregroundColor(.gray)
        }
        .padding()
    }
}

// 複数の Shape を一つの型で扱うためのラッパー
struct AnyShape: Shape {
    private let closure: (CGRect) -> Path

    init(_ shape: S) {
        self.closure = { shape.path(in: $0) }
    }

    func path(in rect: CGRect) -> Path {
        closure(rect)
    }
}

Tap でアニメーション付きで形状が変わる。withAnimation(.spring()) で自然な動きになる。

iOS 18 での新機能と対応

ResizableShape

iOS 18 では、一部の Shape がリサイズに対応した。従来は Path でカスタマイズが必要だった部分が、より簡潔に書ける。

ただし、iOS 17 以前との互換性を保つなら、条件分岐で対応版と非対応版を分ける必要がある。

互換性の保ち方

@available(iOS 18, *)
struct iOS18View: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// iOS 17 以前向け
@available(iOS, introduced: 13.0, deprecated: 18.0)
struct LegacyView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

実装時の注意点

resizable() は必須

Image のデフォルトサイズは固定。clipShape を使う前に .resizable() を必ず呼ぶ。さもないと期待サイズに拡大されない。

frame は clipShape の前

順序が大事。frame を先に指定して、その後に clipShape を適用する。逆順だと期待通りにクリップされない。

// ✓ 正しい順序
Image("icon")
    .resizable()
    .frame(width: 150, height: 150)
    .clipShape(Circle())

// ✗ 間違い
Image("icon")
    .clipShape(Circle())
    .frame(width: 150, height: 150)
    .resizable()

高い解像度では shadow を避ける

clipShape + shadow は、複数の render pass が必要になり、パフォーマンスが低下する場合がある。本当に必要でない限り、shadow は避ける。

複雑な Path は計算量に注意

カスタム Path を定義する場合、複雑な計算は避ける。View の再描画のたびに path() が呼ばれるため、重い計算は実行時パフォーマンスに影響する。

よくある間違いと対処

「クリップが効かない」

原因は、大体 .resizable() が漏れているか、frame が指定されていない。Image のデフォルトサイズで試しているケースが多い。

「アニメーション中に形状がちらつく」

clipShape アニメーションの制限。見栄えが重要なら、mask で代替えすること。

「カスタム Shape がうまく動かない」

Shape の path() メソッドが rect に対応しきれていない。デバッグは、異なるサイズで試して、形状が正しく拡大縮小されるか確認。

関連記事

まとめ

clipShape で Image をカスタム形状にクリップするのは、SwiftUI では基本的なテクニック。使い分けは単純。

シンプルな形(丸、角丸)なら clipShape。複雑な形やグラデーションマスクなら mask。この基準で判断すれば、9 割のケースは解決する。

5 つのパターンを把握していれば、ほぼすべての画像クリッピング要件に対応できるはずだ。

Android PDF 作成と表示【完全ガイド】PdfDocument vs PdfRenderer【2026年版】

はじめに

Android アプリで PDF を作成・表示することは、レポート生成、請求書作成、ドキュメント共有など、実務的なアプリケーション開発で頻繁に必要になります。

この記事では、Android で PDF を扱う 2 つの主要な方法(PdfDocumentPdfRenderer)を完全解説します。2026 年時点での最新の API、Kotlin での実装、トラブルシューティングまでをカバーしています。

PdfDocument とは

概要

PdfDocument は、Android 5.0(API 21)で導入された PDF 生成用のクラスです。アプリケーション内でプログラマティックに PDF を作成・レンダリングできます。

  • 用途:PDF ファイルの作成(ジェネレーション)
  • 対応 API:API 19 以上
  • クラスandroid.graphics.pdf.PdfDocument

メリット・デメリット

項目 メリット デメリット
柔軟性 完全なカスタマイズが可能 実装が複雑
依存性 外部ライブラリ不要 Android Framework に依存
パフォーマンス 中程度(小~中規模 PDF) 大規模 PDF では遅延の可能性
用途 レポート、請求書、領収書作成 複雑なレイアウトは困難

PdfRenderer とは

概要

PdfRenderer は、Android 5.0(API 21)で導入された PDF 表示・レンダリング用のクラスです。既存の PDF ファイルを画像としてレンダリングして表示します。

  • 用途:PDF ファイルの表示・レンダリング
  • 対応 API:API 21 以上
  • クラスandroid.graphics.pdf.PdfRenderer

メリット・デメリット

項目 メリット デメリット
シンプル 実装が簡潔 カスタマイズが限定的
パフォーマンス 高速(ネイティブレンダリング) メモリ使用量が多い可能性
出力形式 高品質な画像として表示 PDF ファイル自体の編集は不可
用途 PDF ファイルの表示・閲覧 PDF 生成には不適

PdfDocument vs PdfRenderer 比較表

項目 PdfDocument PdfRenderer
目的 PDF 生成 PDF 表示
対応 API API 19+ API 21+
入力 アプリケーションコード PDF ファイル
出力 PDF ファイル ビットマップ画像
使用難度 中程度(複雑) 簡単
カスタマイズ性 高い 低い
パフォーマンス 中速 高速
主な用途 レポート、請求書、データエクスポート PDF ビューア、ドキュメント表示

実装方法

前提条件(環境セットアップ)

// build.gradle.kts (Module: app)
android {
    compileSdk = 34  // 2026年推奨:API 34-35

    defaultConfig {
        minSdk = 21    // PdfDocument/PdfRenderer対応の最小値
        targetSdk = 34
    }
}

dependencies {
    // Kotlin stdlib
    implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
}

PdfDocument での実装例

用途:レポート、請求書、ドキュメント作成

// Kotlin での実装例(2026年版)
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.os.Environment
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class PdfReportGenerator(private val context: Context) {

    suspend fun generatePdfReport(): File = withContext(Dispatchers.IO) {
        // PDF ドキュメント作成
        val pdfDocument = PdfDocument()
        val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create()
        val page = pdfDocument.startPage(pageInfo)
        val canvas = page.canvas

        // ページに描画
        val paint = Paint().apply {
            textSize = 16f
        }

        canvas.drawText("サンプル PDF レポート", 50f, 50f, paint)
        canvas.drawText("生成日時: 2026年4月15日", 50f, 100f, paint)
        canvas.drawText("内容: Android PDF 生成のデモンストレーション", 50f, 150f, paint)

        pdfDocument.finishPage(page)

        // ファイル保存
        val pdfFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "report.pdf")
        pdfDocument.writeTo(pdfFile.outputStream())
        pdfDocument.close()

        return@withContext pdfFile
    }
}

// 使用例
// val generator = PdfReportGenerator(context)
// val pdfFile = generator.generatePdfReport()

PdfRenderer での実装例

用途:PDF ファイルの表示・閲覧

// Kotlin での実装例(2026年版)
import android.content.Context
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import java.io.File

class PdfViewerUtil(private val context: Context) {

    fun renderPdfPage(pdfFile: File, pageNumber: Int): Bitmap? {
        return try {
            val fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
            val pdfRenderer = PdfRenderer(fileDescriptor)

            // ページ数確認
            if (pageNumber >= pdfRenderer.pageCount) {
                return null
            }

            // ページをビットマップとしてレンダリング
            val page = pdfRenderer.openPage(pageNumber)
            val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
            page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)

            page.close()
            pdfRenderer.close()

            bitmap
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }
}

// 使用例
// val viewer = PdfViewerUtil(context)
// val bitmap = viewer.renderPdfPage(pdfFile, 0)
// imageView.setImageBitmap(bitmap)

ライブラリを使った簡単実装

より高度な PDF 操作が必要な場合、以下のライブラリを検討してください。

iText(商用・オープンソース)

dependencies {
    // iText 5 (無料版)
    implementation("com.itextpdf:itextg:5.5.13.3")
}

PDFBox(Apache オープンソース)

dependencies {
    // Apache PDFBox
    implementation("org.apache.pdfbox:pdfbox-android:2.0.27.0")
}

権限設定(AndroidManifest.xml)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- ファイル読み書き権限(Android 13以前)-->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <!-- Android 12+: Scoped Storage自動対応 -->

</manifest>

Kotlin での実装(2026年版 - モダン Android)

coroutine を使った非同期 PDF 生成

// Kotlin Coroutine での実装例
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class PdfGeneratorViewModel(private val generator: PdfReportGenerator) : ViewModel() {

    fun generatePdfAsync() {
        viewModelScope.launch {
            try {
                val pdfFile = generator.generatePdfReport()
                // UI 更新
                println("PDF 生成完了: ${pdfFile.absolutePath}")
            } catch (e: Exception) {
                // エラーハンドリング
                println("PDF 生成エラー: ${e.message}")
            }
        }
    }
}

// UI 層での使用例(Jetpack Compose)
@Composable
fun PdfGeneratorScreen(viewModel: PdfGeneratorViewModel) {
    Button(onClick = { viewModel.generatePdfAsync() }) {
        Text("PDF を生成")
    }
}

Android 12+ での変更点

Scoped Storage

Android 12 以降、外部ストレージへのアクセス方法が変わりました。

  • 変更点WRITE_EXTERNAL_STORAGE 権限が無視される
  • 推奨方法getExternalFilesDir() を使用(アプリ固有ディレクトリ)
  • 代替手段FileProvider または Intent.ACTION_CREATE_DOCUMENT
💡 推奨実装パターン:Android 12+ では getExternalFilesDir() を使用してアプリ固有ディレクトリにファイルを保存することが標準実装です。
// Android 12+ での推奨実装
val pdfDir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
val pdfFile = File(pdfDir, "report_${System.currentTimeMillis()}.pdf")

// ファイルの保存・共有
val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile)

// 共有インテント
val shareIntent = Intent(Intent.ACTION_SEND).apply {
    type = "application/pdf"
    putExtra(Intent.EXTRA_STREAM, fileUri)
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

よくあるエラーと対処法

FileNotFoundException

【エラーメッセージ】
java.io.FileNotFoundException: /storage/emulated/0/Documents/report.pdf (Permission denied)
【原因】
- ファイルの保存先に権限がない
- ディレクトリが存在しない
【対処法】
下記のように、Android 12+ 推奨の方法でファイルを保存してください。
// ❌ 間違い
val pdfFile = File("/sdcard/Documents/report.pdf")

// ✓ 正解(Android 12+ 推奨)
val pdfFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "report.pdf")
// または
val pdfFile = File(context.cacheDir, "report.pdf")

SecurityException(Android 12+)

【エラーメッセージ】
java.lang.SecurityException: Permission Denial: opening provider android.content.ContentProvider
【原因】
- 外部ストレージへのアクセス権限不足
- FileProvider の設定不備
【対処法】
1. FileProvider を res/xml/file_paths.xml で設定
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="documents" path="Documents/" />
</paths>

2. AndroidManifest.xml で宣言
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

OutOfMemoryError(大規模 PDF)

【エラーメッセージ】
java.lang.OutOfMemoryError: Failed to allocate [size] bytes
【原因】
- 複数ページの PDF を一度にメモリに読み込み
- 高解像度でレンダリング
【対処法】
// ページごとにレンダリング(メモリ効率化)
fun renderPdfPageSafely(pdfFile: File, pageNumber: Int): Bitmap? {
    return try {
        val fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
        val pdfRenderer = PdfRenderer(fileDescriptor)

        val page = pdfRenderer.openPage(pageNumber)

        // 低解像度でレンダリング(メモリ節約)
        val scale = 1.5f
        val bitmap = Bitmap.createBitmap(
            (page.width * scale).toInt(),
            (page.height * scale).toInt(),
            Bitmap.Config.RGB_565  // ARGB_8888 より軽い
        )
        page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)

        page.close()
        pdfRenderer.close()

        bitmap
    } catch (e: OutOfMemoryError) {
        e.printStackTrace()
        null
    }
}

実装例のリソース

関連記事

まとめ

Android で PDF を扱う場合、以下の判断基準で実装方法を選択してください。

  • PDF を生成したい場合PdfDocument を使用(または iText などのライブラリ)
  • PDF を表示・閲覧したい場合PdfRenderer を使用
  • 複雑な PDF 操作が必要な場合iTextPDFBox などのライブラリを導入

2026 年時点では、Kotlin + Coroutine での非同期処理が標準的な実装パターンです。また、Android 12 以降の Scoped Storage への対応は必須です。

この記事で紹介したコード例は、実際のプロジェクトに適応させて使用してください。不明な点や実装時のトラブルは、コメント欄でお気軽にお尋ねください。

iOS UIViewController ライフサイクル完全ガイド【2026年UIKit + SwiftUI対応】

iOS UIViewControllerライフサイクル【2026年UIKit + SwiftUI 対応】

📌 関連記事: この記事は 【iOS】UIViewControllerのライフサイクル (Swift)(2019年)の2026年版アップデートです。UIKit に加えて SwiftUI 対応を追加しました。

2019年の記事では UIViewControlller のライフサイクルを紹介していた。2026年は状況が変わった。SwiftUI が標準になりつつあり、UIKit は「レガシー」扱いになりつつある。

ただ、既存プロジェクトや複雑な UI は UIKit が必須。両方理解すべき。


2019年 vs 2026年:フレームワークの立場

【2019年】

  • UIKit が主流
  • SwiftUI は登場したばかり(iOS 13)
  • ほぼ全員が UIKit を使っていた

【2026年】

  • SwiftUI が標準(iOS 16+対応アプリが主流)
  • UIKit は「既存プロジェクト対応」用途へ
  • 新規プロジェクト:SwiftUI 推奨
  • 既存プロジェクト:UIKit を理解する必要

UIKit:UIViewControllerのライフサイクル

全ライフサイクルメソッド(実行順序)

ビュー表示時

1. init(coder:) または init(nibName:bundle:)
   ↓
2. viewDidLoad()
   ↓
3. viewWillAppear(_:)
   ↓
4. viewWillLayoutSubviews()
   ↓
5. viewDidLayoutSubviews()
   ↓
6. viewDidAppear(_:)

ビュー非表示時

1. viewWillDisappear(_:)
   ↓
2. viewDidDisappear(_:)
   ↓
3. deinit (メモリから削除)

各メソッドの役割

class MyViewController: UIViewController {

    // ① 初期化時(1回のみ)
    override func viewDidLoad() {
        super.viewDidLoad()
        // UI の初期化、データの読み込み
        // 重い処理はここで実行 OK
    }

    // ② ビュー表示直前(毎回呼ばれる)
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // データの更新、画面リフレッシュ
        // 他の ViewController からの復帰時
    }

    // ③ レイアウト計算前
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        // Auto Layout の前処理
        // ビューのサイズが確定する前
    }

    // ④ レイアウト計算完了
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // サイズに依存した処理
        // フレーム値が確定した後
    }

    // ⑤ ビュー表示完了
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // アニメーション開始
        // ネットワーク通信開始
        // センサー (GPS など) の監視開始
    }

    // ⑥ ビュー非表示直前
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // タイマー停止
        // ネットワーク通信キャンセル
    }

    // ⑦ ビュー非表示完了
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // センサーの監視停止
        // リソース解放
    }

    // ⑧ メモリから削除
    deinit {
        // 最終クリーンアップ
        print("MyViewController が削除されました")
    }
}

SwiftUI:View のライフサイクル

SwiftUI では全く異なる

SwiftUI は 宣言型 UI で、UIViewControlller のような「ライフサイクルメソッド」は存在しない。

代わりに onAppear / onDisappear を使う。

struct ContentView: View {
    @State var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
        .onAppear {
            // ビュー表示時
            print("View appeared")
            // データ読み込み、API 呼び出しはここ
        }
        .onDisappear {
            // ビュー非表示時
            print("View disappeared")
            // リソース解放
        }
    }
}

SwiftUI のライフサイクル(概念的)

【初期化・表示】

  1. View 作成(body 計算)
  2. onAppear 実行
  3. ビュー表示

【状態変更】

  1. @State 更新
  2. body 再計算
  3. ビュー更新(自動)

【終了】

  1. onDisappear 実行
  2. View 削除

UIKit vs SwiftUI:ライフサイクル比較表

フェーズ UIKit SwiftUI
初期化 init(coder:) View 作成
初回ロード viewDidLoad() onAppear
表示前 viewWillAppear() なし(自動)
レイアウト viewWillLayoutSubviews() 自動(Combine)
表示完了 viewDidAppear() onAppear
非表示前 viewWillDisappear() なし(自動)
非表示完了 viewDidDisappear() onDisappear
終了 deinit View 削除

実装パターン:よくある用途別

UIKit版パターン 1:データの初期化

class UserProfileViewController: UIViewController {
    var user: User?

    override func viewDidLoad() {
        super.viewDidLoad()
        // ❌ ここで API 呼び出しは避ける
        // loadUser()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // ✅ ここで最新データを取得(毎回)
        loadUser()
    }

    func loadUser() {
        API.fetchUser { [weak self] user in
            self?.user = user
            self?.updateUI()
        }
    }
}

SwiftUI版パターン 1:データの初期化

struct UserProfileView: View {
    @State var user: User?
    @State var isLoading = false

    var body: some View {
        VStack {
            if let user = user {
                Text(user.name)
            }
        }
        .onAppear {
            loadUser()
        }
    }

    func loadUser() {
        isLoading = true
        API.fetchUser { user in
            self.user = user
            isLoading = false
        }
    }
}

UIKit版パターン 2:センサーの監視開始・停止

class MapViewController: UIViewController {
    var locationManager: CLLocationManager?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // ✅ ビュー表示時に監視開始
        locationManager = CLLocationManager()
        locationManager?.startUpdatingLocation()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // ✅ ビュー非表示時に監視停止
        locationManager?.stopUpdatingLocation()
    }
}

SwiftUI版パターン 2:センサーの監視開始・停止

struct MapView: View {
    @StateObject var locationManager = LocationManager()

    var body: some View {
        VStack {
            Text("Latitude: \(locationManager.latitude)")
        }
        .onAppear {
            locationManager.startUpdating()
        }
        .onDisappear {
            locationManager.stopUpdating()
        }
    }
}

class LocationManager: NSObject, ObservableObject {
    @Published var latitude = 0.0
    let manager = CLLocationManager()

    func startUpdating() {
        manager.startUpdatingLocation()
    }

    func stopUpdating() {
        manager.stopUpdatingLocation()
    }
}

UIKit版パターン 3:タイマー

class CountdownViewController: UIViewController {
    var timer: Timer?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.updateCountdown()
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        timer?.invalidate()  // ❌ これを忘れるとメモリリーク
        timer = nil
    }
}

SwiftUI版パターン 3:タイマー

struct CountdownView: View {
    @State var count = 10
    @State var timer: Timer?

    var body: some View {
        Text("\(count)")
            .onAppear {
                timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                    count -= 1
                }
            }
            .onDisappear {
                timer?.invalidate()  // 自動クリーンアップ
            }
    }
}

メモリリークを避けるためのベストプラクティス

UIKit

❌ メモリリーク:self をキャプチャしすぎ
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    API.fetchData { data in
        self.updateUI(data)  // self が保持され続ける
    }
}
✅ weak self で回避
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    API.fetchData { [weak self] data in
        self?.updateUI(data)  // self が解放される
    }
}

SwiftUI

// SwiftUI は自動的に管理してくれる場合が多い
struct MyView: View {
    @StateObject var viewModel = MyViewModel()

    var body: some View {
        Text(viewModel.data)
            .onAppear {
                viewModel.load()  // 自動的に適切にハンドル
            }
    }
}

2019年 vs 2026年:推奨される実装方針

状況 2019年の推奨 2026年の推奨
新規プロジェクト UIKit SwiftUI
既存 UIKit プロジェクト UIKit 継続 UIKit 継続(段階的に SwiftUI 導入)
複雑な UI UIKit UIKit(SwiftUI の限界回避)
学習用 UIKit SwiftUI

まとめ

【2019年】

UIViewController のライフサイクルを理解 = iOS 開発の基本

【2026年】

  • 新規プロジェクト:SwiftUI を使い、onAppear/onDisappear で対応
  • 既存プロジェクト:UIViewController ライフサイクルは依然重要
  • 両方の理解が必須(業界の過渡期)

実装判断:

  • iOS 16+ のみ対応 → SwiftUI
  • iOS 15 以下対応が必要 → UIKit
  • iOS 14 以下対応が必要 → 必ず UIKit

参考資料

Ollama + Open WebUI:ローカルLLM構築【2026年完全ガイド】

ローカルで LLM を動かすなら Ollama + Open WebUI が標準。2025年の古い記事では断片的だったが、2026年版は実装から運用まで全て網羅。


なぜローカル LLM なのか

【クラウド API(ChatGPT など)】

  • 毎回データが外に出る(セキュリティ問題)
  • API 代がかさむ(月数千~数万円)
  • 速度が遅い場合がある(ネットワーク遅延)

【ローカル LLM(Ollama)】

  • データが自分のサーバーだけ(セキュアー)
  • 無料(初期構築後、ランニング費用ほぼゼロ)
  • 速度が速い(ネットワーク遅延ゼロ)

必要な環境

ハードウェア

【最小構成】

  • CPU:4コア以上(Ryzen 5 相当)
  • メモリ:8GB(推奨 16GB)
  • ストレージ:30GB(モデルサイズ次第)

【GPU あると大幅に高速化】

  • Nvidia GPU:RTX 3060 以上推奨
  • AMD GPU:RX 6700 XT 以上推奨
  • Mac M1/M2:統合 GPU で十分

【GPU なし場合】

  • CPU のみでも動作(ただし遅い)
  • 推論速度:1トークン/秒程度
  • GPU あり場合:10~20倍高速

ソフトウェア

【必須】

  • Docker
  • Docker Compose

【推奨(GPU使用時)】

  • Nvidia GPU ドライバー
  • CUDA Toolkit 12.0+

インストール手順

ステップ 1:Docker & Docker Compose インストール

# Ubuntu/Debian
sudo apt-get update
sudo apt-get install docker.io docker-compose

# 権限設定(sudo なしで実行可能に)
sudo usermod -aG docker $USER
newgrp docker

ステップ 2:docker-compose.yml 作成

version: '3.8'

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
    gpus:
      - driver: nvidia
        all: true  # GPU 全て使用
    restart: always

  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    ports:
      - "8080:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    depends_on:
      - ollama
    restart: always
    volumes:
      - webui_data:/app/backend/data

volumes:
  ollama_data:
  webui_data:

ステップ 3:起動

docker-compose up -d

# ログ確認
docker-compose logs -f

ステップ 4:モデルのダウンロード

# Ollama コンテナに入る
docker exec -it ollama bash

# モデルをダウンロード
ollama pull llama2:7b
# または
ollama pull mistral:7b
# または
ollama pull neural-chat:7b

ステップ 5:Open WebUI にアクセス

ブラウザで http://localhost:8080 を開く
→ ユーザー登録(初回のみ)
→ モデル選択して利用開始

GPU 設定(高速化のため)

Nvidia GPU の場合

# CUDA Toolkit インストール
sudo apt-get install cuda-toolkit-12-0

# docker-compose.yml で GPU を有効化(上記の例参照)
services:
  ollama:
    gpus:
      - driver: nvidia
        all: true

GPU が認識されているか確認

# コンテナ内で確認
docker exec -it ollama bash
ollama run llama2:7b

# プロンプトで GPU 使用状況を確認
# "GPU acceleration enabled" と表示されたら OK

推奨モデル一覧(2026年)

モデル サイズ 速度 品質 用途
Llama 2 7B 4GB 速い 標準 汎用・最初の1択
Mistral 7B 4GB 高速 高い 日本語・コード
Neural Chat 7B 4GB 速い 高い 会話型・日本語
Llama 2 13B 8GB 中程度 高い より精度重視
Code Llama 7B 4GB 速い コード特化 プログラミング
Mistral Large 34GB 遅い 非常に高い 重い処理用

初心者推奨:Mistral 7B(バランス型)


よくある落とし穴

1. GPU が認識されない

# 確認コマンド
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi

# 出力がなければ、nvidia-docker が必要
sudo apt-get install nvidia-docker2
sudo systemctl restart docker

2. メモリ不足エラー

"OOM Killed" または メモリ不足エラーが出た場合

対策 1:小さいモデルを使う

ollama pull llama2:7b  # 7B は 4GB
# ではなく
ollama pull phi:2.7b   # 2.7B は 2GB

対策 2:スワップメモリを増やす

sudo fallocate -l 16G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

3. Open WebUI が Ollama に接続できない

# docker-compose.yml で
environment:
  - OLLAMA_BASE_URL=http://ollama:11434

# これが正しくないと接続失敗
# ホスト名は「ollama」(service 名)
# ポートは 11434(Ollama のデフォルト)

4. 応答が遅い(GPU 未使用の場合)

# GPU が使われているか確認
docker exec -it ollama ollama ps

# "GPU acceleration disabled" が出たら GPU 未認識
→ 上記「GPU が認識されない」の対策を実施

実装時のベストプラクティス

1. モデルの永続化

volumes:
  ollama_data:/root/.ollama  # ← ここにモデルが保存される

# コンテナ削除後も モデルが残る
docker-compose down  # コンテナ削除
docker-compose up -d  # 再起動(モデルダウンロード不要)

2. Web UI のセキュリティ設定

open-webui:
  environment:
    - OLLAMA_BASE_URL=http://ollama:11434
    # ローカルホストのみアクセス許可
    - WEBUI_AUTH_REQUIRED=true  # ユーザー認証必須

3. 複数モデルの管理

# ダウンロード済みモデルを確認
docker exec -it ollama ollama list

# 不要なモデルを削除
docker exec -it ollama ollama rm llama2:7b

4. バックアップ

# モデルデータをバックアップ
docker cp ollama:/root/.ollama ./ollama_backup

# WebUI のデータ(会話履歴)をバックアップ
docker cp open-webui:/app/backend/data ./webui_backup

トラブルシューティング

ポート競合

"Address already in use" エラーが出た場合

docker ps  # 既存コンテナを確認
docker stop <コンテナ ID>
docker-compose up -d  # 再起動

メモリリーク

# コンテナのメモリ使用量を監視
docker stats

# メモリが増え続ける場合、モデルを再ロード
docker restart ollama

ネットワーク接続の問題

# コンテナ間の通信確認
docker exec -it open-webui ping ollama

# 失敗する場合、ネットワークドライバを確認
docker network ls
docker network inspect <network_name>

2025年版との変更点

項目 2025年 2026年
モデルサイズ Llama 2 のみ 複数選択肢
GPU 設定 簡潔 詳細なトラブルシューティング
セキュリティ 未記載 ユーザー認証設定
バックアップ 未記載 手順明記
トラブルシューティング なし 充実

次のステップ

【1日目】

  • Ollama + Open WebUI をセットアップ
  • モデル 1つをダウンロード・テスト

【1週間後】

  • GPU 最適化を試す
  • 複数モデルを試して相性を確認

【1ヶ月後】

  • 実運用に向けて、セキュリティ設定を強化
  • API 化(他アプリから Ollama を呼び出し)を検討

参考資料

Android Jetpack Compose LazyGrid で斜めスクロール実装【2026年版】

Android 斜めスクロールView実装 ~2026年Jetpack Compose版

📌 関連記事: この記事は 【android】ScrollViewで縦横斜めにスクロール(2015年)の2026年版アップデートです。当時のコンセプトが、今どう実装されているかをご紹介します。

かつて「Androidで斜めスクロールって実装できないのか?」という問い合わせがあった。2015年時点では標準APIになく、自作が必須だった。今は Jetpack Compose の登場で、状況が大きく変わった。

2026年なら、Compose + LazyGrid で 1行で解決できる。


昔の問題(2015年)vs 現在(2026年)

2015年:框組みで解決しようとしていた時代

ScrollView(縦)+ HorizontalScrollView(横)
  ↓
マージして「斜めスクロール」を実現
  ↓
カスタムViewGroupを自作

当時は効果測定も「コンセプト段階」で、実装されていなかった。

2026年:Jetpack Compose なら標準

LazyVerticalGrid / LazyHorizontalGrid
  ↓
フリングスクロール、慣性スクロール完備
  ↓
nested scroll も自動対応

標準APIs の充実で、カスタム実装が不要になった。


2026年の実装:Jetpack Compose

ステップ 1:依存関係の追加

dependencies {
    implementation "androidx.compose.foundation:foundation:1.7.0"
    implementation "androidx.compose.material3:material3:1.3.0"
    implementation "androidx.activity:activity-compose:1.9.0"
}

ステップ 2:LazyGrid で斜めスクロール実現

@Composable
fun DiagonalScrollView() {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        modifier = Modifier
            .fillMaxSize()
            .padding(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(100) { index ->
            GridItem(index)
        }
    }
}

@Composable
fun GridItem(index: Int) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(120.dp),
        colors = CardDefaults.cardColors(
            containerColor = Color(0xFF6200EE)
        )
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Item $index",
                color = Color.White,
                fontSize = 16.sp,
                fontWeight = FontWeight.Bold
            )
        }
    }
}

ステップ 3:複数方向スクロール(横 + 縦)

単純な縦スクロール + 横スクロール両対応が必要なら:

@Composable
fun BiDirectionalScroll() {
    LazyVerticalGrid(
        columns = GridCells.Fixed(4),
        modifier = Modifier
            .fillMaxSize()
            .horizontalScroll(rememberScrollState())
    ) {
        items(200) { index ->
            GridItem(index)
        }
    }
}
注意:
  • horizontalScroll()LazyVerticalGrid でも使用可能
  • フリングスクロール、慣性スクロールは自動対応
  • パフォーマンスは Compose の仮想化により最適化済み

実装時の落とし穴

1. NestedScrollConnection の設定を忘れずに

親スクロールと子スクロールが両方ある場合:

val nestedScrollDispatcher = remember { NestedScrollDispatcher() }

LazyVerticalGrid(
    columns = GridCells.Fixed(3),
    modifier = Modifier
        .nestedScroll(nestedScrollDispatcher.asNestedScrollConnection())
) {
    // ... items
}

2. パフォーマンス:アイテム数が大きい場合

❌ 悪い例:すべてのアイテムを描画
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    repeat(10000) { index ->
        GridItem(index)  // 全項目メモリに残る
    }
}
✅ 良い例:仮想化による遅延描画
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
    items(10000) { index ->
        GridItem(index)  // 見える範囲だけ描画
    }
}

3. スクロール位置の保存

ユーザーが別の画面から戻ってきた時、スクロール位置を復元:

val listState = rememberLazyGridState()

LazyVerticalGrid(
    columns = GridCells.Fixed(3),
    state = listState
) {
    items(100) { index ->
        GridItem(index)
    }
}

// スクロール位置を保存
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collect { index ->
            // ローカルDBに保存
            saveScrollPosition(index)
        }
}

2015年実装 vs 2026年実装:比較

項目 2015年 2026年
フレームワーク View(XML + Java) Jetpack Compose(Kotlin)
コード行数 100~200行 30~50行
パフォーマンス メモリ負荷大(全アイテム保持) 軽量(仮想化)
実装難度 中~高(カスタムViewGroup必須) 低(標準APIで解決)
フリングスクロール 手実装 自動対応
nested scroll対応 複雑 シンプル
テスト容易性 高(Composable テスト用ツール充実)

代替案:特殊な要件がある場合

要件 1:斜め45度のカスタムスクロール

@Composable
fun DiagonalCustomScroll(
    angle: Float = 45f  // 45度斜めスクロール
) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        modifier = Modifier
            .fillMaxSize()
            .rotate(angle)  // 斜めに回転
    ) {
        items(100) { index ->
            GridItem(index)
        }
    }
}

要件 2:スクロール速度をカスタマイズ

@Composable
fun CustomSpeedScroll() {
    val scrollState = rememberScrollState()

    Column(
        modifier = Modifier
            .verticalScroll(
                scrollState,
                flingBehavior = ScrollableDefaults.flingBehavior()
            )
    ) {
        repeat(100) { index ->
            Text("Item $index", modifier = Modifier.padding(16.dp))
        }
    }
}

まとめ

2015年:「カスタムViewGroup自作が必須」

2026年:「Compose の LazyGrid で 30行で完成」

Android 開発は急速に進化している。かつての「困難な実装」が「標準機能」に変わるのは珍しくない。

古い記事の実装方法に固執するのではなく、2026年のプラットフォーム能力を活用する方が圧倒的に効率的。


参考資料