はじめに
SwiftUIで開発する小説執筆支援アプリを作ってみる。
あ、当然Nolaさまには文句があるわけでも何でもなく、ただただ自身の学習のために作っています。
それに、私は有料会員でもあるんですよ!〜
目的
とりあえず達成したいことは、
- Nolaノベルさまのような、執筆画面。
- キャラクターの管理画面
- フラグや伏線の管理
- ルビなどのレンダー
- APIを公開している小説サイトへの投稿管理
- そうでないサイトでも無理やり投稿できるようにする
技術選定
- SwiftUI
とりあえずこれだけで始めた。パッケージを使う際は都度追加していく。
開発記録
セットアップ
SwiftUIを使ったmacOSのセットアップをした。
//
// ContentView.swift
// Novel
//
// Created by SerikaYuzuki on 2025/06/05.
//
import SwiftUI
import AppKit
import Observation
struct ContentView: View {
@State private var doc = NovelDocument()
var body: some View {
(text: $doc.text)
NovelTextView.frame(minWidth: 500, minHeight: 600)
.navigationTitle("Novel Draft")
.toolbar {
// 手動保存ボタンも一応
("Save Now") {
Button.saveNow()
doc}
}
}
}
/// 小説本文モデル(Swift 5.9+ 新 Observation)
@Observable
final class NovelDocument {
/// 画面と双方向バインディングされる本文
var text: String = "" {
didSet { scheduleSave() }
}
// MARK: - Private
private let saveURL: URL
private var saveWorkItem: DispatchWorkItem?
init() {
let dir = FileManager.default.urls(for: .documentDirectory,
in: .userDomainMask).first!
self.saveURL = dir.appendingPathComponent("Novel.txt")
// 起動時にロード
self.text = (try? String(contentsOf: saveURL, encoding: .utf8)) ?? ""
}
/// 2 秒間入力が止まったら保存(前回予約があればキャンセル)
private func scheduleSave() {
?.cancel()
saveWorkItemlet currentText = text
let url = saveURL
let task = DispatchWorkItem {
try? currentText.write(to: url, atomically: true, encoding: .utf8)
}
= task
saveWorkItem .main.asyncAfter(deadline: .now() + 2, execute: task)
DispatchQueue}
/// 明示的に即時保存したいときに呼ぶ
func saveNow() {
try? text.write(to: saveURL, atomically: true, encoding: .utf8)
}
}
/// SwiftUI から使う AppKit NSTextView ラッパ
struct NovelTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
// スクロールビュー
let scroll = NSScrollView()
.hasVerticalScroller = true
scroll
// NSTextView(inset 0)
let textView = IndentAwareTextView()
.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.usesFindPanel = true
textView.font = .userFixedPitchFont(ofSize: 14)
textView.delegate = context.coordinator
textView
// --- visual & layout fixes ---
.backgroundColor = .textBackgroundColor // 1. 背景ハッキリ
textView.textColor = .labelColor // 2. 常に見える文字色
textView.drawsBackground = true // 3. 透明禁止
textView
.autoresizingMask = [.width] // follow scroll width
textView.minSize = NSSize(width: 0, height: 0)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude,
textView: CGFloat.greatestFiniteMagnitude)
height.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView
// スクロールビューと結合(*ここ*より後に containerSize を設定する)
.documentView = textView
scroll
.textContainer?.containerSize = NSSize(
textView: scroll.contentSize.width,
width: CGFloat.greatestFiniteMagnitude)
height.textContainer?.widthTracksTextView = true
textView
.typingAttributes = [
textView.foregroundColor: NSColor.labelColor,
.font: textView.font ?? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular)
]
// SwiftUI→AppKit の初期値
.string = text
textView.coordinator.textView = textView
contextreturn scroll
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
// AppKit→SwiftUI の反映は delegate 側で行うのでここは空
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: NovelTextView
weak var textView: NSTextView?
init(_ parent: NovelTextView) { self.parent = parent }
func textDidChange(_ notification: Notification) {
guard let tv = textView, tv.string != parent.text else { return }
.text = tv.string
parent}
}
}
/// 改行時に自動字下げするカスタム NSTextView
final class IndentAwareTextView: NSTextView {
private let indent = " "
override func insertNewline(_ sender: Any?) {
super.insertNewline(sender) // ← まず普通に改行
super.insertText(indent, replacementRange: selectedRange()) // 字下げ
}
}
// #Previewブロックの中では、このコンテナビューを呼び出すだけにする
{
#Preview ()
ContentView}
Optional
SwiftのOptionalは、nil
を許容するかどうかを表す型。
// Swift
var name: String? // これは「名前があるかもしれないし、ないかもしれない」変数
= "さくら" // 値を入れる
name (name) // Optional("さくら")
print
= nil // 値がない状態にする
name (name) // nil print
Rustでやってた時の、Option<T>
というtypeに近いことをやっていそう。unwrapする必要がないみたい。
Observartion
WWDC2023で発表された 機能。デレゲートってわけでもなく、コンパイラーが勝手にクラス内のVariableの変更を検知してくれるとかっていう摩訶不思議なことをやっているらしい。
Apple公式で説明がされてるんだが、マジでわかりやすかった。開発者としてこういうのが手厚いといいよねと思う。他の言語やライブラリって、手書きのドキュメントは充実してることはあっても、こういうのはないよね。ちょっと新鮮。
ただ、swiftはドキュメントがあまり充実してないのは、ちょっと困る。
えーと、……どういうこと?
内部で実際に何が行われているのかはわからないが、
import SwiftUI
import Observation
@Observable
class CounterModel {
var count = 0
}
struct ContentView: View {
@State private var model = CounterModel()
var body: some View {
{
VStack ("カウント: \(model.count)")
Text("増やす") {
Button.count += 1
model}
}
.padding()
}
}
こんな感じで書くと、ボタンを押したらUIが勝手にリフレッシュして、カウントが増えた状態のUIにアップデートされるみたい。
Grand Central Dispatch
Concurrent execution を手伝ってくれるクッソ有能なフレームワーク。とりあえず今はディレイをかけることに使っているので、そのSample Codeだけ載せておく。
import Foundation
let task = DispatchWorkItem {
("3秒後に実行されるよ")
print}
.main.asyncAfter(deadline: .now() + 3, execute: task)
DispatchQueue
// もし途中でキャンセルしたい場合
// task.cancel()
main
というのは、メインスレッドのこと。UIとかはこのスレッドに入れて処理させてる。メインスレッド以外にはhigh, default, low, background
という優先度があって、DispatchQueue.global(qos: .background)
とかで指定できる。
sync, async
は、synchronously execute させるかどうか。sync
をmain
で使うと、終わるまでプロセスが固まる。