[Swift] 54 visionOSアプリ製作 その2 タップ検知 / CollisionComponent

[Mac M2 Pro 12CPU, Ventura 13.6, visionOS 1.0 (21N5300a), Xcode 15.1 beta 3]

ようやく3Dモデルへのタップを検知できるようになりました。

11/4にタップ検知実装に着手してから3週間も掛かりました。あまりの開発効率の悪さに我ながら引いています。

解決後、よく調べてみるとAppleが提供しているサンプルコード HappyBeamでCollisionComponentを扱っていました。

単なるEntityではなくusdzファイルを読み込ませたModelEntityでないと上手くいかないようです。デフォルトのSceneエンティティはただのEntityなのでハナから無理筋だったのか。

UnityでもvisionOSアプリを開発できるものの、Unity Pro(年額26.8万円)の登録が必須になっています。2年分でVision Proを購入できます。まあ個人開発者には厳しいですね。

そのうちUnity Personal(無料)に登録してMeta Quest用アプリを作ってみたいですが、visionOSアプリ開発にしばらく注力します。macOSやWindowsのように複数のアプリを起動できるvisionOSの方が私には魅力的です。

[Swift] 53 visionOSアプリ製作 その1 ECSの分割管理

[Mac M2 Pro 12CPU, Ventura 13.6, visionOS 1.0 (21N5300a), Xcode 15.1 beta 3]

visionOSアプリを製作しています。

Entity Component Systemアーキテクチャ(ECS)の扱いに手間取っています。初めはReality Composer Proを使ってRealityKitContent内でECSを一元管理しようと試みましたが、ContentViewにあるボタンとの連携がうまく出来ません。そこで自製ComponentについてはPackages外にECSディレクトリを設けて別管理することにしました。

[Swift] 52 BBS閲覧アプリiOS版製作 その6 スレッド表示 / datファイル取得

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

掲示板サイトからdatファイルをダウンロードするところで、結構時間がかかりました。

ちょっとした手違いで非同期関数が非同期的に動作せず、ダウンロード完了の手前で終わっていました。

今はdatファイルの内容をそのまま表示しています。次はこれをHTMLに加工します。

datファイルはiPhone内のコンテナに保存しています。エディタで内容を確認するため可視ファイルにしたいのですが、今のところできていません。

掲示板名(横線)スレッドタイトル(横線)スレッド内容、を縦に並べている
import SwiftUI

struct datView: View {
    var board: Board?
    var thread: Thread?
    @State var datPath: String = ""
    @State var datContent: String?

    var body: some View {
        VStack(spacing: 8) {
            if let board = board {
                Text(board.name)
                    .font(.system(size: 16))
                Divider() // 横線
            }
            if let thread = thread {
                Text(thread.threadTitle)
                    .font(.system(size: 16))
                Divider()
            }
            // datファイルの内容を表示する
            if let content = datContent {
               Text(content)
            }
            Spacer()
        }
        .padding(.horizontal, 0)
        .padding(.top, 0)
        .padding(.bottom, 0)
        .task {
            if let thread = thread, let board = board {
                datPath = await makeDat(id: thread.threadID, url: board.url)
                datContent = await readDatFile(path: datPath)
                if datContent?.isEmpty ?? true {
                    print("datなし")
                }
            }
        }
        .refreshable {
            if let thread = thread, let board = board {
                datPath = await makeDat(id: thread.threadID, url: board.url)
                datContent = await readDatFile(path: datPath)
                if datContent?.isEmpty ?? true {
                    print("datなし")
                }
            }
        }
    }
}

struct datView_Previews: PreviewProvider {
    static var previews: some View {
        datView()
    }
}
今のところアプリ本体は852KB

[Swift] 51 BBS閲覧アプリiOS版製作 その5 文字列分割 / TestFlight登録 / Vision Pro

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

手掛けている掲示板のHTMLから取得する文字列はスレッド番号、タイトル、投稿数が結合したものです。これを分割して、Viewに横並びさせました。

このアプリを公開する予定はないのですが、未経験のTestFlightに登録だけしてみました。内部テスターとして自分を登録し、iOSのTestFlightアプリからインストールしました。非公開のまま知り合いだけ内部テスターとして登録し、配布しても面白そうです。内部テスターは100人、外部テスター(アプリ要審査承認)は10000人まで登録できます。

Xcodeベータ版のVision Proシミュレータでも使ってみました。Vision ProではiPad版UIで使用できます。

import Foundation

struct ThreadContent{
    let threadNum: Int
    let threadTitle: String
    let threadPostNum: Int
}

func splitTitle(threadString: String) async -> ThreadContent {
    var threadContent: ThreadContent
    var colonIndex: String.Index?
    var openParenIndex: String.Index?
    var number: Int = 0
    var title: String = ""
    var postCount: Int = 0
    
    // 番号を取り出す(最初のコロンの左側)
    if let index = threadString.firstIndex(of: ":") {
        colonIndex = index
        if let colonIndex = colonIndex {
            number = Int(threadString[..<colonIndex]) ?? 0
            print("番号: \(number)")
        }
    }
    
    if let index = threadString.lastIndex(of: "(") {
        // タイトルを取り出す(最初のコロンの右が始点、末尾から最初の開き括弧の左が終点)
        openParenIndex = index
        if let colonIndex = colonIndex, let openParenIndex = openParenIndex {
            title = String(threadString[threadString.index(after: colonIndex)..<openParenIndex])
            print("タイトル: \(title)")
        }
        
        // 投稿数を取り出す(開き括弧右から末尾1文字を削除した数字)
        if let openParenIndex = openParenIndex {
            let postCountString = threadString[threadString.index(after: openParenIndex)..<threadString.index(before: threadString.endIndex)]
            postCount = Int(postCountString) ?? 0
            print("投稿数: \(postCount)")
        }

    }
    
    threadContent = ThreadContent(threadNum: number, threadTitle: title, threadPostNum: postCount)
    
    return threadContent
}
TestFlightにアルファ版を登録、配布可能にした
Vision Proシミュレータでテスト

[Swift] 50 BBS閲覧アプリiOS版製作 その4 スレッド一覧表示 / kanna

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

選択した掲示板のスレッド一覧を表示させました。

掲示板のHTMLをパースして要素を取り出し、ChatGPTでC++の関数をSwiftへ変換して動作させました。FLTKに相当するViewについては自分で叩き台を考える必要があります。

Swiftの文法が分かっていればもっとスムーズに書けるはずですが、まあこれから学んでいきます。

次は選択スレッドの表示およびdatファイルの保存に関する実装に取り組みます。

プロジェクトの構成
import Foundation
import Kanna

struct Thread : Identifiable {
    let id = UUID()
    let threadID: String
    let threadTitle: String
    let threadNum: Int
}

func getThreads(url: String) async -> [Thread] { // urlはboard.url
    var threads: [Thread] = []
    var threadNumDict: [String: Int] = [:]
    var threadNum = 0
    
    let boardURL = url + "subback.html"
    guard let url = URL(string: boardURL) else { return [] }

    do {
        let htmlString = try String(contentsOf: url, encoding: .shiftJIS)
        guard let doc = try? HTML(html: htmlString, encoding: .shiftJIS) else { return [] }

        for link in doc.xpath("//*[name()='small']/*[name()='a']") {
            guard let id = link["href"], let title = link.text else { continue }
            
            print(id)
            
            let truncatedID = String(id.dropLast(4))
                
            let idRegex = try! NSRegularExpression(pattern: "^[0-9]+$")
            let idRange = NSRange(location: 0, length: truncatedID.utf16.count)
            let isNumericID = idRegex.firstMatch(in: truncatedID, options: [], range: idRange) != nil
            
            if !isNumericID {
                continue
            }

            if threadNumDict[truncatedID] == nil {
                threadNum += 1
                threadNumDict[truncatedID] = threadNum
            }

            threads.append(Thread(threadID: truncatedID, threadTitle: title, threadNum: threadNumDict[truncatedID]!))
            
            
        }
    } catch {
        print("Error: \(error)")
    }

    return threads
}
import SwiftUI

struct BoardView: View {
    @State private var boardCategories: [BoardCategory] = []

    var body: some View {
        NavigationView {
            VStack{
                Text("板一覧")
                List(boardCategories) { boardCategory in
                    DisclosureGroup(boardCategory.category) {
                        ForEach(boardCategory.boards) { board in
                            NavigationLink(destination: ThreadView(board: board)) {
                                ScrollView {
                                    Text(board.name)
                                }
                            }
                        }
                    }
                }
            }
            Spacer()
        }
        .padding()
        .task {
            boardCategories = await getBoards()
        }
    }
}

struct BoardView_Previews: PreviewProvider {
    static var previews: some View {
        BoardView()
    }
}
import SwiftUI

struct ThreadView: View {
    @State private var threads: [Thread] = []
    var board: Board?

    var body: some View {
        VStack {
            Text("スレッド一覧")
            List{
                ForEach(threads) { thread in
                    Text(thread.threadTitle)
                }
            }
            Spacer()
        }
        .padding()
        .task {
            if let board = board {
               threads = await getThreads(url: board.url)
            }
        }
    }
}

struct ThreadView_Previews: PreviewProvider {
    static var previews: some View {
        ThreadView()
    }
}

[Swift] 49 BBS閲覧アプリiOS版製作 その3 掲示板一覧表示 / kanna(libxml2)でスクレイピング

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

BBS閲覧アプリiOS版の初期画面として掲示板一覧をツリー表示させました。カテゴリーのリストを登場順に並べるのに少し手間取りました。

ChatGPT(gpt-4)をうまく誘導しながら完成させました。構造体で解決しなくて安易にクラスにしようとするなど一々大げさな反応をするので、こちらで手綱を引かないと無駄に大掛かりなコードになります。しかもどんどん的外れな方向に進んでいくので要注意です。

今日ここまでのgpt-4利用料は330円($2.19)ほど。新しい言語なのでgpt-3.5はなるべく避けたいところですが、やはりgpt-4はここぞという時に使うようにします。

import Foundation
import Kanna

struct Board : Identifiable {
    let id = UUID()
    let url: String
    let name: String
    let category: String
    let categoryNum: Int
}

struct BoardCategory : Identifiable {
    let id = UUID()
    let category: String
    let boards: [Board]
    let categoryNum: Int
}


func getBoards() async -> [BoardCategory] {
    var boards: [Board] = []
    var categoryNumDict: [String: Int] = [:]
    var categoryNum = 0
    guard let url = URL(string: "掲示板一覧のURL") else { return [] }

    do {
        let htmlString = try String(contentsOf: url, encoding: .shiftJIS)
        guard let doc = try? HTML(html: htmlString, encoding: .shiftJIS) else { return [] }

        for link in doc.xpath("//*[name()='a']") {
            guard let url = link["href"], let name = link.text else { continue }

            var category = "XXX" // カテゴリー名の初期値
            var prevElement = link.previousSibling
            while prevElement != nil && prevElement?.tagName != "b" {
                prevElement = prevElement?.previousSibling
            }
            if let prevElement = prevElement {
                category = prevElement.text ?? "XXX"
            }

            if categoryNumDict[category] == nil {
                categoryNum += 1
                categoryNumDict[category] = categoryNum
            }

            boards.append(Board(url: url, name: name, category: category, categoryNum: categoryNumDict[category]!))
        }
    } catch {
        print("Error: \(error)")
    }
    
    let groupedBoards = Dictionary(grouping: boards, by: { $0.category })
    
    var boardCategories: [BoardCategory] = groupedBoards.map { BoardCategory(category: $0.key, boards: $0.value, categoryNum: categoryNumDict[$0.key]!) }
    boardCategories.sort { $0.categoryNum < $1.categoryNum }

    return boardCategories
}

[Swift] 48 BBS閲覧アプリiOS版製作 その2 Swift Package Manager / Kanna

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

BBS閲覧アプリiOS版のプロジェクトにHTMLパーサー Kannaを導入しました。パッケージ管理システム Swift Package Managerを使いました。

CocoaPodsとは打って変わっての使いやすさでした。さすが純正は違います。2019年10月リリースのXcode 11からコマンドラインだけでなくIDEで使用可能になったようです。

[File] – [Add Package Dependencies]から右上窓にGitHubのURLを入力する

[Swift] 47 BBS閲覧アプリiOS版製作 その1 CocoaPods / Kanna

[Mac M2 Pro 12CPU, Ventura 13.6, iOS 17.0.3, Xcode 15.0]

BBS閲覧アプリC++版をiOSへ移植します。

HTMLパーサーとしてKannaを使います。パッケージ管理システム CocoaPodsを使って導入しました。CocoaPodsはHomebrewでインストールしています。

悪戦苦闘しつつようやくビルドエラーにならない状態まで持ってこれましたが、この段階でApple純正のパッケージ管理システム Swift Package Manager(SPM)の存在を知りました。

次回、SPMでKannaを入れ直します。愚痴ってもしょうがないですが、Xcodeを使ったコーディングは色々とめんどくさいです。ライブラリ一つ導入するまでの時間的学習コストが高すぎます。ネット情報は古い陳腐化したものが優勢で油断するとミスリードされてしまいます。古い情報なのに最近の日付で更新となっているからタチが悪い。慣れているのもありますが、C++の方がのびのびと書けてストレスが少ないです。

掲示板リストはまだ表示できない

[Swift] 46 メモアプリ製作 その13 背景色のグラジエント

[Mac M2 Pro 12CPU, Ventura 13.6, watchOS 10.0.1, Xcode 15.0]

Apple Watchは表示面が小さくメモによっては全体を一度に表示できません。表示されているところがメモ全体のどこに当たるのか分かるようにするため、背景色をグラジエントにしました。

これでメモの下部を表示していても背景色が薄いので上部が隠れていると判断でき、メモの見落としを防ぐことができます。

スクロールバーでも同様の効果がありますが、watchOSでは常時表示にはできないため役に立ちません。

改良前:メモ1を見落とすおそれあり(実際は付番していないため)
改良後:メモ上部が隠れていることが分かる
.background(LinearGradient(gradient: Gradient(colors: 
[Color.blue, Color.blue.opacity(0.0)]), startPoint: .top, endPoint: .bottom))
struct ContentView: View {
    @Environment(\.managedObjectContext)var viewContext

    @FetchRequest(
    entity: Note.entity(),
    sortDescriptors: [NSSortDescriptor(key: "creationDate", ascending: false)])
    private var contents: FetchedResults<Note>

    var body: some View {
        NavigationView{
            List{
                ForEach(contents){content in
                    NavigationLink{
                        if((content.content?.isEmpty) == false){
                            DraftAppleWatch(text:content.content!, note: content)
                        }
                    }label:{
                        if((content.content?.isEmpty) == false){
                            Text(content.content!)
                            .font(.system(size: 20))
                            .background(LinearGradient(gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.0)]), startPoint: .top, endPoint: .bottom))
                        }
                    }
                }
                .onDelete(perform:deleteContent)
            }
            .navigationTitle("メモリスト")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar{
                ToolbarItem(placement:.bottomBar){
                     NavigationLink{
                        DraftAppleWatch()
                    }label:{
                        Text("+")
                        .font(.system(size: 24))
                    }
                }
            }
        }
        .accentColor(.blue)
    }
<以下略>
ScrollView {
       Text(content.content!)
       .font(.system(size: 20))
       .background(LinearGradient(gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.0)]), startPoint: .top, endPoint: .bottom))
}
スクロールしないと
右上のバーは表示されない

[Swift] 45 Apple WatchのComplication改良 コーナー表示で効かない修飾子

[Mac M2 Pro 12CPU, Ventura 13.6, watchOS 10.0.1, Xcode 15.0]

自製カレンダーのコーナーComplicationを改良しました。日付と元号の位置を入れ替えています。日付を本体として配置すると曜日がはみ出るため、ラベルの方に移動させました。

最初は日付と曜日が本体として収まるようフォント指定あるいは自動調整しようとしたものの、関連する修飾子はどれも効きませんでした。

また右上の天気に色を付けたいのですが、類似アプリを探すか自分で作るしかなさそうです。自製するなら天気APIを調査するところから着手でしょうか。

右下コーナー:曜日がはみ出る
曜日はラベルへ移動
struct ComplicationCorner : View {
    var entry: Provider.Entry
    
    var body: some View {
        Text(getFormattedDate())
//        Text(getFormattedDate() + getFormattedWeekday()) // 曜日は収まらないのでラベルへ
        .foregroundColor(.green)
        .minimumScaleFactor(0.5) // 効かない
        .font(.system(size: 60)) // 効かない
        .lineLimit(nil)          // 効かない
        .widgetCurvesContent()
        .widgetLabel {
            Text(getFormattedYear() + " " + getFormattedWeekday())
            .foregroundColor(.yellow)
            .font(.system(size: 20))
        }
        .containerBackground(for: .widget){
            Color.blue
        }
     }
<以下略>