macOSアプリでiOSアプリのようなListViewを実現する

Swift
MacOSアプリでiOSアプリのようなListViewを実現する

はじめに

macOS向けアプリ開発をしてみるシリーズ。食わず嫌いはよくないと思います。

以前macOSアプリでListを作ろうとして、表計算ソフトのような表が現れたのをみて「そうじゃぁ無いんだ……」とがっかりしておりました。もしかすると、CocoaPodsを利用したりScrollViewを活用すればできたのかもしれないですが、当時はそこでiOSの方に興味が移ってしまったわけです。

月日が流れSwiftUIが登場した今、もしかしたらできるのではという淡い期待とともに、改めて挑戦してみようと思った次第です。

下準備

プロジェクトの準備

新しいプロジェクトを作っても良いのですが、せっかくなので流用します。

Vaporアプリケーション経由でMongoDBにアクセスし、本の情報を読み書きすることを目指すアプリケーションです。じわじわっと育てていきます。

方針

基本となるアプリケーションはすでに用意した段階からのスタートになります。
データモデルもすでに存在する状態です。

  • ListViewの各行を表すViewを作る
  • ListViewを作る
  • Vaporアプリケーションに、本の情報一式をJSONで返してもらうRouteを作る
  • ListViewに表示する

ひとまずここまで。
ListViewの各行をクリックすると詳細が表示されたり、絞り込み検索をしたり、情報の更新をしたりなどは次のステップです。

ListViewを表示することが今回の主目的です。

Viewを作っていく

各行を表すViewを作る

小さいところから作っていきます。まずはListViewに表示する各行のViewを作っていきます。

本の情報はひとまとめにして構造体として持たせるようにしています。Viewにその構造体をもたせられるようにしておきます。

struct BookRow: View {
  var bookData:Book

    var body: some View {
        Text("stub")
    }
}

併せてPreviewProviderも調整しておきます。初期化ができていないとビルドエラーが出てしまいます。ダミーデータなので内容は適宜で大丈夫です。

struct BookRow_Previews: PreviewProvider {
    static var previews: some View {
        BookRow(bookData: Book.init(title: "TitleDummy", publisher: "PubDummy", price: 1234))
    }
}

仕上げに本の情報を表示するためにViewを整えていきます。ここはプレビューを見ながら進めていきましょう。

VStack(alignment: .leading) {
    Text(bookData.title)
        .font(.body)
        .fontWeight(.bold)
        .opacity(1.0)
        .truncationMode(.tail)
        .frame(maxWidth: .infinity, alignment: .leading)
    Spacer()
    HStack(alignment: .center) {
        Text(bookData.publisher)
            .font(.footnote)
            .fontWeight(.regular)
            .opacity(0.75)
            .truncationMode(.middle)
            .frame(maxWidth: .infinity, alignment: .leading)
        ThousandsText(value: $bookData.price)
            .font(.footnote)
            .opacity(0.75)
            .truncationMode(.middle)
            .frame(maxWidth: 80, alignment: .trailing)
    }.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: 48.0)
.padding(8.0)

モディファイアもりもりにしました。盛りすぎて見通しが悪くなったので、ほどほどの所でViewModifierにまとめてしまいたいです、

それっぽいCellView

数値を3桁区切りで表示するViewを作っておく

ところでThousandsTextとは?

数値を3桁区切りで表示したいというのはよくある話です。ベタな方法は、都度Formatterを使って3桁区切りに変換することですが、せっかくなので、3桁区切りで表示するViewを作っておこうと思います。
使いまわしできて便利です。

さっき出てきたThousandsTextはこれです。

方針としては数値を渡すと3桁に区切って表示してくれるViewです。シンプル。

struct ThousandsText: View {
    @Binding var value:Int

    var body: some View {
        Text(separate(value))
    }

    var separate = { (price:Int) -> String in
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.groupingSeparator = "," // 区切り文字をここで指定
        formatter.groupingSize = 3 // 区切り文字を入れる桁数をここで指定
        return  formatter.string(from: NSNumber(integerLiteral: price)) ?? "\(price)"
    }
}

表示させる値には、Binding属性を付けています。このViewを呼び出すViewで管理しているので。

ListView本体を作る

行を表すパーツができたので、それを並べるListViewを作っていきます。

なお、今回はMongoDBのドキュメントを表示させるので、MongoDBとのやり取りをさせるためのクラスを別途作っています。安直にAccess2Mongoクラスと命名。
MongoDBから取得したドキュメント一式を持っているのが、そのクラスのプロパティbooksです。booksの中身はただの配列なので、ForEachで1つずつループさせています。

基本はデータをForEachで順繰りに呼び出して、それをさっき作ったViewに渡しているだけです。

struct BookList: View {
    @EnvironmentObject private var access2mongo:Access2Mongo
    @Binding var selectedBook: Book?

    var body: some View {
        List(selection: $selectedBook) {
            ForEach(access2mongo.books, id: \.self) { book in
                BookRow(bookData: book)
            }
        }
    }
}

VaporアプリケーションにRouteを追加する

Vaporアプリケーションを通してMongoDBからドキュメントを取得します。

Leafテンプレートにデータを渡してWebページを表示させることに興じていたため、ドキュメントそのものを取得するRouteがなくなっていました。なぜ消したのか。

復習も兼ねてVapor4対応でサックリと作ってみます。全件取得バージョン。

app.get("api","data","books") { req -> EventLoopFuture<[BookData]> in
    let client = req.application.mongoClient
    let collection = client.db("vaporapp").collection("books", withType: Book.self)

    var books = [BookData]()
    return collection.find().flatMap { cursor in
        cursor.toArray()
    }.flatMap { booksDocs -> EventLoopFuture<[BookData]> in
        for bookDoc in booksDocs {
            let book = BookData(id: bookDoc._id.hex, title: bookDoc.title, publisher: bookDoc.publisher, price: bookDoc.price)
            books.append(book)
        }
        return req.eventLoop.future(books)
    }
}

Routeパスが少し残念な事になっていますが、LeafテンプレートによるView生成と共存させるためにあえてこうしておきました。生のデータを取得するという意味でdataではなくrawでも良いかもしれない。

命名にはいつも悩まされます。

ListViewを表示する

さてと。いよいよ最後です。ContentViewにListViewを表示させます。Viewの入れ子が続いていましたが、最後です。

そそっと並べて、モディファイアで整形です。

struct ContentView: View {
    @EnvironmentObject private var access2mongo:Access2Mongo
    @State var books:[Book] = []

    @State private var selectedBook:Book?

    var body: some View {
        GeometryReader{ geometry in
            VStack {
                Text("Hello, World!")
                    Button(action: {
                        self.access2mongo.get()
                    }) {
                    Text("Get Document")
                }
                BookList(selectedBook: self.$selectedBook)
            }
            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
        }
    }
}

動かそう

動かしてみましょう。

まずはウィンドウが開いた状態。

データを表示する前の初期状態

ここでGetDocumentボタンをクリックすると、データの取得とList表示がなされます。

MongoDBのデータを表示してみました

まとめ

macOSでもiOSのようなListViewを作ることができました。
それにしても、ロジックを切り離してViewに集中できるのは幸せなことですな〜