ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

数年前に書いた習作のmacOSアプリをSwiftUIで書き直したら、意外とハマりどころがあって面白かった話

はじめに

本記事はSwift/Kotlin愛好会 Advent Calendar 2020の17日目の記事です。

qiita.com

数年ぶりにアドベントカレンダーに記事を書いてみたいと思って、思いついたのがSwift/Kotlin愛好会アドベントカレンダーでした。
会自体に参加したことはないので、来年は参加できるといいなと思っています。

概要

なんとなくSwiftUIでアプリのViewを書いてみたくなって、数年前にSwiftの勉強用に書いたmacOSアプリをSwiftUIで書き直してみました。
意外とハマりどころがあって面白かったので、誰かのお役に立てるかと思ってブログにまとめました。

実装環境

  • macOS 10.15.7
  • Xcode 12.2

AdobeなどBig Surでの動作に不安がありそうなアプリケーションを使用する機会が多く、Catalinaで実装しています。
最新版のSwiftUIではないので、すでに解消されているハマりどころもあるかもしれません。

ソースコード

GitHubにあげています。

github.com

RunningAppsターゲットがAppKit版、RunningAppsSwiftUIがSwiftUI版です。

書き換え対象のアプリケーション

起動しているアプリケーションを一覧表示して、行をクリックしたらそのアプリケーションがアクティブになる、いわゆるランチャー系のmacOSアプリです。
当時はSwiftUIもなかったので、AppKitで書いています。

当時の記事

www.macneko.com

構造はシンプルで、ViewControllerにNSTableViewを設置して、Cellを表示しているだけです。
アーキテクチャはMVVMを採用してはいます。

アプリケーションが起動・終了したときにViewを更新するロジックとして、Cocoa Bindingを採用しています。
なぜRxSwiftなどのライブラリを使用していないかというと、当時使ったことがなくて学習コストが高いと感じたのと、早く動くものを作らないと飽きて捨てることになるからで、深い意味はないです。

書き換えたアプリケーションのスクリーンショット

https://user-images.githubusercontent.com/5406126/102351849-74d5d500-3fea-11eb-9e21-50b18ea97b21.png

左がSwiftUI版、右がAppKit版です。
背景色がちょっと違いますが、ご愛嬌ということにしました。

書き換えにあたってハマったところ

さて、ここからが本題です。 いくつかのパートにわけて私がハマったところを紹介します。 私の知識不足が原因でSwiftUIの問題ではない部分もあるかと思いますが、そこはTwitterなどでやんわりご指摘いただけるとうれしいです。

ScrollViewでハマったところ

スクロールできなくなった

ScrollView のインジケータを表示したくないなぁと思って showsIndicatorsfalse にしたところ、スクロールできなくなりました。
いろいろ調べたのですが原因がわからず、なんとなくtrue に戻したところ、スクロールできるようになりました。
インジケータの値がスクロール可能かどうかに影響するとは…。

採用したコードは以下のような内容です。

// showsIndicatorsをfalseにするとスクロールできなくなるのでtrueにしている
ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}

ウィンドウサイズを縮小したらクラッシュするようになった

AppKit版ではウィンドウサイズを一定の幅より縮めることはできず、好きな幅に拡げられるようになっていました。
SwiftUI版では少し仕様を変えて幅は子Viewの最大幅にして、伸縮できないようにすることにしました。
その試行錯誤の過程で ScrollViewframe(maxWidth: geometry.frame(in: .global).width, maxHeight: geometry.frame(in: .global).height) を設定したところ、ウィンドウサイズを指定したサイズより小さくすると「Contradictory frame constraints specified.」でクラッシュするようになってしまいました。

(この記事を書くために再現するコードを書こうと試行錯誤したのですが、クラッシュせずに普通に縮小できてしまいました。
ScrollView だけじゃなく、子Viewにも frame を設定していたので、そのあたりも影響していそうです)

調べたところ、以下の記事を見つけました。

swiftui-lab.com

この記事によると、どうやら最小サイズや理想的なサイズが最大サイズより大きくなってはいけないのに、ウィンドウを縮小したときにその制約が壊れてクラッシュしているっぽいです。
ですよねー。
そこで、 GeometryReader を使ってglobalのサイズを使ってたのをやめて minWidth だけ指定するようにしたら、サイズを変えてもクラッシュしないし、指定したいサイズより小さくできなくなりました。

さらに調べたところ、子Viewの中の一番大きなサイズにFitさせる fixedSize() の存在を知りました。
しかし、 fixedSize() を指定すると高さもFitするようになり、起動しているアプリケーションの数を増やすと行がMacの画面サイズをこえてしまって、かつスクロールできなくなってしまいました。
引数なしの場合は、幅も高さもFitさせるので、想定通りですね。
メソッドの引数を指定して fixedSize(horizontal: true, vertical: false) としたら幅は子Viewの最大幅、高さは前回終了時の高さ?で起動し、、スクロールもできるようになりました。
いろいろつまづきましたが、これで私がやりたかったことを実現できました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}
// 子View内の最大サイズにあわせてFitする
.fixedSize(horizontal: true, vertical: false)

Buttonでハマったところ

グレーのViewが表示されて、そのViewだけしかタップできない

NSTableViewCell のように行全体をタップ範囲にすることを期待して、 Buttonlabel にViewを設定したところ、期待していたとおりにはいかず、 Button の真ん中あたりにグレーのViewが表示されただけでした。
子Viewの幅とも高さとも一致しておらず、タップ範囲もこのグレーの部分のみになっていました。

https://user-images.githubusercontent.com/5406126/102468266-a5724900-4094-11eb-9a8e-a4ef29e13861.png

画像の状態のコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}

なにかしら設定があるだろうとAppleのドキュメントを眺めていたところ、 buttonStyle の存在を知り、その中の PlainButtonStyle() を設定したところ、 label で指定したViewのサイズに沿った透明ボタンが作成されることがわかりました。
最初からドキュメント読みましょうという話ですね…。

採用したコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}
// これを指定するとボタン内のグレーのViewがなくなり、AppListViewのサイズの透明ボタンができる
.buttonStyle(PlainButtonStyle())

行の余白部分をタップしても反応しない

上述した処理でグレーのViewを消し去ることに成功したものの、今度は文字や画像はタップできるが、行の余白部分はタップできないという問題が発生しました。

この問題には結構時間を使ってしまったのですが、思いつきで Buttonforground で青を設定したら原因がわかりました。
以下の画像のように、アイコンと青い文字列のみが Button として認識されていたのでした。背景部分にはViewがないのでタップできないということですね。

https://user-images.githubusercontent.com/5406126/102470675-a2c52300-4097-11eb-80d8-056093b2da40.png

UIButton も同じ挙動になるので、理由がわかったときはかなりがっかりしました。
つい数ヶ月前にハマっているんですよね、これ…。

この問題は、 Buttonlabel に指定するView側で background() を指定して背景にViewを敷き、かつ contentShape() を指定することで、Viewの最大幅と最大高さまでタップ範囲を広げることができました。

採用したコードは以下のような内容です。

HStack {
    Image(nsImage: metaData.icon ?? NSImage())
        .resizable()
        .renderingMode(.original)
        .aspectRatio(contentMode: .fit)
        .frame(width: 36, height: 36, alignment: .center)

    VStack(alignment: .leading) {
        Text(metaData.name)
            .font(.system(size: 12, weight: .bold, design: .default))
            .padding(.bottom, 7)

        Text(metaData.versionDescription)
            .font(.system(size: 12, weight: .regular, design: .default))
    }
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// 背景に透明なViewを敷く
.background(Rectangle().foregroundColor(.clear))
// contentShapeを設定する
.contentShape(Rectangle())

ForEachでハマったところ

各行の間に余分なpaddingが設定されてしまう

このアプリでは ScrollView の中で ForEach を使用して行となる各Viewを生成するようにしたのですが、各Viewの間に私が設定していない余分なpaddingが設定されてしまう問題が生じました。
ScrollViewButtonDivider のすべてに padding(0) を設定しても解消しなかったので、どこかで私の関知していないViewが生成されているようです。

調べたところ、以下の記事を見つけました。

www.reddit.com

記事によると、 ForEach でViewを生成すると内包したViewの間にpaddingが設定されてしまうようです。
この問題は、 ScrollViewForEach の間に VStack(spacing: 0) を追加することで、paddingを消すことができました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // ForEachで作られたView間にpaddingが設定されてしまうので、↓のVStack(spacing: 0)が必要
    // via https://www.reddit.com/r/SwiftUI/comments/e607z3/swiftui_scrollview_foreach_padding_weird/
    // 行の揃えもVStackで指定する
    VStack(alignment: .leading, spacing: 0) {
        ForEach(viewModel.metaData) { data in
            // 子Viewをなんか実装する
        }
    }
}
.fixedSize(horizontal: true, vertical: false)

Listでハマったところ

macOSではSeparatorが引かれない

List での実装を試したときに、なぜか Separator が引かれないという問題が生じました。
「SwiftUI List Separator」というワードでググってもiOSの情報しかなく、ほとんどが「Separatorを消すにはこうするとよいです!」という内容のものでした。
違うんだ、私はSeparatorを引きたいんだ…。

List 内に Text を描画するだけのシンプルなアプリを実装して試したのですが、iOSは自動でSeparatorが引かれるのに対して、macOSではやはりSeparatorは引かれませんでした。

試したコードは以下のような内容です。

struct ContentView: View {
    var body: some View {
        List(0 ..< 5) { index in
            Text("number \(index)")
        }
    }
}

OS間の差異なんですかねぇ。

この問題は、自前で Divider を引くことでSeparatorを引くことができました。

余白部分がタップできない

Button の項でも同じ問題にハマっていましたが、 List でもハマってしまいました。
ListScrollView に書き換えると問題なく動作するので、別の問題が起きていそうです。

これに関しては ScrollView に書き換えれば動くことがわかっているので、諦めました。

参考

SwiftUIの実装を始める前に佐藤さんの同人誌を読んで勉強して、実装中も何度も読み直しました。
実際に機能のあるアプリケーションを実装するパートもあって、読み応えがあります。
私は同人誌版を購入したのですが、現在は商業出版されているので、そちらのリンクを紹介します。

www.amazon.co.jp

まとめ

初めて自分でSwiftUIを使って実装したのですが、面白いですね。
Storyboardで実装しているときの触っただけで差分発生するイライラから解放されるって素晴らしい。
Previewも最高ですね。
AppKitとUIKitの違いに振り回されることも少なそうなので、今後macOSアプリを書くことがあればSwiftUIを採用すると思います!