[SwiftUI] Listでifを使うと初回ロードが重くなる...?
Listを使って縦スクロール型のビューを組んでいて、初回表示が重くなる問題にぶち当たっています。
実際のコードはもっと複雑ですが、やりたいことはこんな感じです。
List内の各行はItem.typeごとに別々のビューを表示させるようにしています。
struct ContentView: View { var body: some View { List { ForEach(Model.items) { ItemView(item: $0) } } } } struct ItemView: View { let item: Item var body: some View { if item.type == .type1 { Item1View(item: item) } else { Item2View(item: item) } } }
一見問題なさそうに見えます。
表示が崩れたりといった問題もないのですが、このifを使ったビュー構造だと初回ロードが重くなってしまうようなのです。
_printChanges()
を使って、ItemViewが呼ばれるタイミングを見てみます。
struct ItemView: View { let item: Item var body: some View { let _ = Self._printChanges() // 追加 if item.type == .type1 { Item1View(item: item) } else { Item2View(item: item) } } }
すると、画面上は8アイテム分しか表示されていないのにもかかわらず、ForEachに渡しているアイテム数分(今回は100で試しました)、ItemViewの初期化が行われていることがわかりました。
画面はこんな感じ。
Xcodeコンソールの表示(100行出力されている)。
Memory Graphを見てみると、セルは8アイテム分のみメモリにロードされているようでした。
ifを使わずにItem1Viewだけを表示するようにしてみると、7アイテム分のみItemViewの初期化が行われました(Item1Viewの方がItem2Viewより高さが大きいため7アイテム分の表示になる)。
struct ItemView: View { let item: Item var body: some View { let _ = Self._printChanges() // if item.type == .type1 { Item1View(item: item) // } else { // Item2View(item: item) // } } }
画面。
Xcodeコンソールの表示(7行出力されている)。
Memory Graph。
List内部の仕組みはよくわかっていませんが、ifの分岐があることで高さ計算が複雑になって全アイテム数分初期化している、みたいなことなのでしょうか...?
これが根本原因なのかはまだわかってないのですが、初回ロードが重くなる現象に関係があると睨んでいます。
なお、以下のようにForEach直下でif文を使った場合も同じで、Model.itemsの全アイテム数分初期化処理が行われました。
※debugPrint()
についてはこちらをご参照ください。
struct ContentView: View { var body: some View { List { ForEach(Model.items) { item in if item.type == .type1 { Item1View(item: item) .debugPrint("\(item.id)") } else { Item2View(item: item) .debugPrint("\(item.id)") } } } } }
ちなみに、ScrollView+LazyVStackを使うと、if文を使っても画面に表示されている分しか初期化処理されませんでした。
LazyVStackなので想定通りですね。
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(Model.items) { ItemView(item: $0) } } } } } struct ItemView: View { let item: Item var body: some View { if item.type == .type1 { Item1View(item: item) } else { Item2View(item: item) } } }
ただ、LazyVStackだとアイテム数分のビューをすべてメモリに保持してしまうので、アイテム数が多いと下にスクロールしていくとどんどん重くなっていくという別の問題が出てきます。
このためScrollView+LazyVStackに単純に置き換えればいいというわけでもないので、なかなか苦労しています。。
GeometryReader自体の高さを子ビューのフレームサイズと同じにする
記事タイトルをどうすべきか結構悩んだんですがw、要はやりたいのはこういうことです。
Rectangleの高さを、与えられたフレーム幅を基に動的に変化させたいとします。
ここではフレーム幅の半分のサイズの高さを指定することとします。
struct ContentView: View { var body: some View { VStack { Text("Hello") GeometryReader { geometry in Rectangle() .fill(Color.blue) .frame(width: geometry.size.width, height: geometry.size.width / 2) } Text("Hello") } } }
こんな画面になります。
Rectangleのフレームサイズはframe(width:height:)
で指定した通りですが、GeometryReader自体は「Hello」から「Hello」までのフレームサイズを持っています。
GeometryReader自体のフレームサイズを指定していないので、親であるVStack内の利用可能な領域いっぱいまでフレームサイズが広がるようです。
次に、VStackをScrollView内に入れてみます。
struct ContentView: View { var body: some View { ScrollView(.vertical) { // 追加 VStack { Text("Hello") GeometryReader { geometry in Rectangle() .fill(Color.blue) .frame(width: geometry.size.width, height: geometry.size.width / 2) } Text("Hello") } } } }
すると、こんな画面になります。
ScrollViewの中に入ったので、GeometryReaderの高さが不定?になり、Rectangleが下の「Hello」の領域まではみ出てしまっています。
子ビューであるRectangleの高さと同じ高さになることを期待してましたが、そうではないようです。
どうすれば期待通りにできるでしょうか?
私は以下のようなやり方で解決しました。
- 子ビュー(Rectangle)の高さを取得する
- 子ビューの高さをGeometryReaderの
frame(height:)
に指定する
実装の全体はこんな感じです。
struct ContentView: View { @State private var rectangleHeight: CGFloat = .zero var body: some View { ScrollView(.vertical) { VStack { Text("Hello") GeometryReader { geometry in Rectangle() .fill(Color.blue) .frame(width: geometry.size.width, height: geometry.size.width / 2) .readHeight($rectangleHeight) } .frame(height: rectangleHeight) Text("Hello") } } } } extension View { func readHeight(_ height: Binding<CGFloat>) -> some View { background(GeometryReader { geometry -> Color in DispatchQueue.main.async { height.wrappedValue = geometry.size.height } return Color.clear }) } }
ビューの高さを取得することができるreadHeight(_:)
というModifierを定義しています。
func readHeight(_ height: Binding<CGFloat>) -> some View
backgroundにGeometryReaderをあてることで、そのビューのフレームサイズを取得することができるという機能を利用しています。
この実装については以下のリンクが参考になると思うので見てみてください。
readHeight(_:)
で取得したRectangleの高さは@Stateプロパティに格納され、それをGeometryReaderの高さとして指定しています。
@State private var rectangleHeight: CGFloat = .zero ... ScrollView(.vertical) { VStack { Text("Hello") GeometryReader { geometry in Rectangle() .fill(Color.blue) .frame(width: geometry.size.width, height: geometry.size.width / 2) .readHeight($rectangleHeight) // ここでRectangleの高さを取得 } .frame(height: rectangleHeight) // Rectangleの高さを指定 Text("Hello") } }
こうすることで、GeometryReaderがRectangle分のフレームサイズを持つことになり、意図した画面にすることができます。
[SwiftUI] インラインでTextにImageを埋め込むデザインを実装しようとして苦労した話
テキストの先頭にアイコンがあり、2行目のテキストの先頭をアイコンの下に潜り込ませるデザインを、あなたはどのように実装しますか?
SwiftUIのTextを使うと、テキストに画像をインラインで埋め込むことができるので、以下のように簡単に実装できます。
Text(Image(systemName: "star")) + Text("寿限無寿限無五劫のすり切れ海砂利水魚の水行末")
では、SF Symbolにない独自のアイコン画像を使いたい場合はどうでしょうか?
同じように、Textを使って実装してみます。
Text(Image("forward10")) + Text("寿限無寿限無五劫のすり切れ海砂利水魚の水行末")
なんかアイコンの位置がおかしい。。
ちょっと上になってる。。
そうなんです、思った通り配置してくれないんです。
他のModifier当ててみたり色々試してみたんですがうまくいかずでした。
そこで、UIKitで要件を実現するビューを作ってみることにしました。
色々試行錯誤してたどり着いた実装がこれです。
struct IconLabel: UIViewRepresentable { func makeUIView(context: Context) -> some UIView { let attachment = NSTextAttachment() attachment.image = UIImage(named: "forward10")! attachment.bounds = CGRect(x: 0, y: -6, width: 24, height: 24) let imageString = NSAttributedString(attachment: attachment) let mutableAttributedString = NSMutableAttributedString() mutableAttributedString.append(imageString) mutableAttributedString.append(NSAttributedString(string: "寿限無寿限無五劫のすり切れ海砂利水魚の水行末")) mutableAttributedString.addAttributes( [.font: UIFont.systemFont(ofSize: 18)], range: .init(location: 0, length: mutableAttributedString.length) ) let label = UILabel() label.attributedText = mutableAttributedString label.numberOfLines = 2 label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return label } func updateUIView(_ uiView: UIViewType, context: Context) { } }
いい感じに配置されています!
ところが、、
以下はプレビューのCanvasの図なんですが、UIViewRepresentableで実装したビューはコンテンツの上下に余白が入ってしまいます。
理想は下図のようにフレームがコンテンツサイズと一致することなんですが、いろいろ試してもここがうまくいきませんでした。
上下に不要な余白が入ってしまうことで、他のビューと組み合わせたときにマージンを調整するのに非常に苦労します。
高さを固定値にしないと使えなかったりと、なかなか自由度の低いビューになってしまいます。
ということで、今のところ要件を実現するビューが実装できていない状況です。
もし良い感じのやり方ご存じの方いたら、コメントで教えていただけると嬉しいです。
[SwiftUI] カスタムビューに独自のModifierを実装する方法
アプリの複数の画面で汎用的に使えるカスタムビューを作ったが、画面によって一部分だけ色を変えたいという要件が出てきたら、どう実装すれば良いでしょうか?
例えば、ユーザ情報を表示する汎用的なビューを以下のように実装したとします(テキスト思いっきりベタ書きしてますがここでは気にしないことにします)。
struct UserView: View { var body: some View { VStack(alignment: .leading) { HStack { Image("icon") .resizable() .scaledToFill() .frame(width: 30, height: 30) Text("Bamboo Hero") } Text("ブログやってます!") } .padding(8) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(Color.black, lineWidth: 1) ) } }
こんなビューです。
複数の画面でこのビューを使い回すことになったとして、ある画面では名前の部分を青色にし、ある画面では黒のままにしたいとします。
例えばこういう実装が考えられます。
struct UserView: View { var nameColor: Color = .black // <- 追加 var body: some View { VStack(alignment: .leading) { HStack { Image("icon") .resizable() .scaledToFill() .frame(width: 30, height: 30) Text("Bamboo Hero") .foregroundColor(nameColor) // <- 追加 } Text("ブログやってます!") } .padding(8) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(Color.black, lineWidth: 1) ) } }
以下のようにすれば、名前部分が黒色のビューと青色のビューを表示できます。
UserView(nameColor: .blue)
UserView()
上記実装でも要件は満たせますが、もうちょっとSwiftUIライクに書きたいですね。
そこで、こういう実装に変えてみます。
struct UserView: View { private var nameColor: Color = .black // <- privateにする var body: some View { VStack(alignment: .leading) { HStack { Image("icon") .resizable() .scaledToFill() .frame(width: 30, height: 30) Text("Bamboo Hero") .foregroundColor(nameColor) } Text("ブログやってます!") } .padding(8) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(Color.black, lineWidth: 1) ) } // 名前の色を変更するためのModifier func nameColor(_ color: Color) -> CustomView { var view = self view.nameColor = color return view } }
こんな感じでSwiftUIライクに実装できるようになりました。
UserView() .nameColor(.blue) UserView()
可変の項目が増えてきてもModifierを追加実装していけば良く、SwiftUIらしい可読性の良いコードが書けます。
UserView() .nameColor(.blue) .bodyTextColor(.red) .borderColor(.yellow)
住信SBIネット銀行の定額自動入金・定額自動振込が便利すぎるので紹介したい
給料が振り込まれた翌日、ATMからお金を下ろして、ローンの口座、貯金の口座、子どもの口座などなどたくさんの口座に預け入れしないといけない。。
手数料もばかにならないし、何よりめんどくさい。。
こういう悩みを抱えている方、結構いるのではないでしょうか?
つい先月まで私も毎月同じことを繰り返していました。
給料が入った翌日はコンビニに向かい、ATMを陣取り、キャッシュカードと札束を何度も財布から出し入れして。。
この面倒な作業どうにかならんもんかと調べてみたら、住信SBIネット銀行の定額自動入金・定額自動振込機能を見つけ、早速導入してみたら便利すぎて感動しました...!!
同じような悩みを抱えている人には是非知っていただきたい。そう思って記事を書きました!
定額自動入金・定額自動振込で実現できること
仕組みはとってもシンプルです。
毎月決まった日に給与口座から指定の金額が住信SBIネット銀行の口座に入金され、毎月決まった日にその口座から別の銀行の口座に指定の金額が振り込まれます。
絵にするとこんな感じ。
今まではコンビニに行って手動でやってたこの作業を、住信SBIネット銀行を使うことで完全に自動化...!
便利すぎる...!
仕組みをもう少し詳しく
定額自動入金
定額自動入金は、他の銀行口座から毎月決まった日に指定の金額を住信SBIネット銀行の口座に自動で入金する機能です。
指定できるのは毎月5日と27日のみです。
※5日と27日は指定口座から引き落としされる日であり、住信SBIネット銀行の口座に入金されるのはその4日後です
なんと手数料は無料です!
設定画面も見やすい。
定額自動振込
定額自動振込は、毎月決まった日に指定の金額を他の銀行口座に自動で振り込みを行う機能です。
こちらは毎月1〜31日の好きな日にちを指定できます。
10件まで登録でき、振込の内容ごとに好きな名前をつけられるので管理もしやすいですね。
気になる振込手数料ですが、振込先が住信SBIネット銀行もしくは三井住友信託銀行であれば無料、その他の銀行でも1件あたり77円と激安!
さらに、会員のランクに応じて振込手数料が一定回数まで無料になるサービスもあります。
ランクは4段階まであって、3以上は個人的になかなか条件が満たしにくかったのですが、ランク2はスマホアプリでスマート認証NEOという設定をするだけでなれるし、月5回まで振込手数料が無料になるので、私の場合は手数料が一切かからずに利用することができています。
ランクについて詳しくはこちら。
こんな便利な機能を使わない手はない
私のメインバンクは大手のメガバンクですが、そこにも自動振込の機能は存在します。
しかし、手数料が高すぎる...!
1回で500円とかするんですよ...
なので仕方なく毎月給料日の翌日にコンビニに通っていました。
でも住信SBIネット銀行に出会い、定額自動入金・定額自動振込を設定してからは、ATMに通う必要は一切なくなりました!
同じようにコンビニATM通いしてる人にこの記事が届きますように。
肥大化したUITextViewをテスタブルで再利用可能な構造にリファクタする
複雑なテキスト処理ロジックを持つテキストエディタを実装しようとすると、UITextViewのカスタムクラスが肥大化することはありませんか?
例えば、ユーザがテキストを入力すると、その内容を検証したり、ハイライトしたり、その他様々な処理を行うという要件があるとします。
これを素直に実装するとこんな感じになると思います。
class CustomTextView: UITextView { override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) commonInit() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } private func commonInit() { delegate = self } private func validate() { // テキストの検証を行う複雑なロジック } private func decorate() { // テキストの装飾を行う複雑なロジック } // その他多数のテキスト処理ロジックが続く... private func ... } extension CustomTextView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { validate() decorate() ... // その他多数のテキスト処理ロジックが続く... } }
上記実装の問題点は3つあると考えます。
- ロジックが増えるたびにクラスが肥大化していき、可読性が悪くなる
- ロジックの再利用ができない
- ロジックのテストができない
要件というのはどんどん増えていくものです。そのたびにこのクラスにロジックが積まれてコード行数が増えていき、可読性が悪くメンテナンスしづらいクラスになっていきます。
構造的にロジックの再利用ができないので、他の画面で似たようなテキストエディタを実装する必要が出てきたときに、同じようなロジックをそちらのクラスにも実装することになります。
さらに、このクラスはユニットテストをすることが非常に困難です。将来ロジックの一部が変更されたり追加されたりするたびに、デグレのリスクに晒されることになります。
では、どうすればこのクラスをテスタブルで再利用可能な構造にすることができるでしょうか?
UITextViewを受け取って設定変更するロジッククラスを作る
割とシンプルな話ですが、テキスト処理ロジックを行う専用のクラスを作り、UITextViewは処理をそのクラスに委譲してしまえば良いのです。
class CustomTextView: UITextView { private let validator = TextValidator() // テキストの検証を行うクラス private let decorator = TextDecorator() // テキストの装飾を行うクラス override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) commonInit() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } private func commonInit() { delegate = self } } extension CustomTextView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { validator.validate(textView: textView) // テキストの検証処理はTextValidatorに委譲 decorator.decorate(textView: textView) // テキストの装飾処理はTextDecoratorに委譲 } } class TextValidator { func validate(textView: UITextView) { ... } } class TextDecorator { func decorate(textView: UITextView) { ... } }
この構造により、上記3つの問題点が解決できます。
- ロジックが増えるたびにクラスが肥大化していき、可読性が悪くなる
→UITextViewのカスタムクラスからロジックの実装が無くなりシンプルになる - ロジックの再利用ができない
→TextValidatorやTextDecoratorは特定のクラスに依存しないので、他のUITextViewのカスタムクラスで再利用できる - ロジックのテストができない
→ユニットテストが書けるようになる。具体例は後述します
余談ですが、自分自身(UITextView)を丸ごと渡して別のクラスに自身のプロパティを変更させるというアーキテクチャが個人的には発見でした。
class TextDecorator { func decorate(textView: UITextView) { // UITextViewを受け取り、textやattributedTextの値を変更する } }
下記のように色々なプロパティを渡して戻り値を受け取るような構造を考えたりしてたのですが、これだとシグネチャが複雑で可読性が悪く、非常に使い勝手の悪いものになってしまいます(というかそもそも動くものが実装できなかった)。
class TextDecorator { /// カーソル位置の文字列を置換してハイライトする、などの処理 func decorate(text: String, currentCursor: UITextRange, replacingText: String, ...) { ... } }
構造を変えたことでユニットテストもしやすくなります。
例えばTextDecoratorのテストはこんな感じで書くことができます。
class TextDecorator { func decorate(textView: UITextView) { // めっちゃシンプルな例ですが... textView.text = "decorated \(textView.text!)" } } class TextDecoratorTests: XCTestCase { func testDecorator() throws { let decorator = TextDecorator() let textView = UITextView() textView.text = "text" decorator.decorate(textView: textView) XCTAssertEqual(textView.text, "decorated text") } }
単純なテクニックですが、構造を変える前と変えた後では保守性が大きく異なります。
もし肥大化したUITextViewのカスタムクラスを見つけたら、上記の方法でリファクタしてみてください。
XcodeGenを導入するにあたりお世話になった記事3選
XcodeGenを導入するぞ!といっても、最初は何から手を付けていいのかわからない人が多いのではないでしょうか?
私も最初はそんな状態でしたが、先人の皆さまが素晴らしい記事を残してくれていたので、年季の入った大規模なプロジェクトにもかかわらずXcodeGen導入をすることができました。
本記事では私が大変お世話になった記事を3つご紹介します。
3つって少なくない?と思うかもしれないですが、実際これだけあれば十分でした!
- mikanのiOSプロジェクトにXcodeGenを導入しました。
- Sansan iOS アプリに XcodeGen を導入しました
- プロジェクトファイルからXcodeGenのproject.ymlを生成する
- おわりに
mikanのiOSプロジェクトにXcodeGenを導入しました。
こちらの記事では、XcodeGen導入にあたり気をつけるべきことを簡潔にまとめてくれています。
XcodeGenの導入にあたって使用すると便利なツールがいくつか紹介されているのですが、細かい使い方の說明ではなく、どういう課題があってその課題に対してツールがどのように有効なのかがわかりやすく解説されています。
ここで紹介されている以下2つのツールは大変便利で、XcodeGen導入においては必須と言えるツールです。
Sansan iOS アプリに XcodeGen を導入しました
1つ目に紹介した記事をもとにproject.yml
を書き始めたのですが、私が相手にしていたのは7、8年もののXcodeプロジェクトでして、一から書いていたのではいつまでも終わりそうにない状態でした。。
そこでこの記事を見つけ読んでいると、project.yml
の自動生成機能が紹介されていて、大変助けられました。
さらに、Tipsとして紹介されているものはどれもとても参考になります。
project.yml
はかなり膨大になるのである程度の単位で細かくファイルを分けると思うのですが、ディレクトリ構成やスクリプトの配置場所などはかなり参考にさせていただきました(というかほぼそのまま採用)。
チームでXcodeGenを運用していくための工夫も参考になります。
たしかに、最初はXcodeGenを導入した人しか詳しくないわけで、チームで運用していくためにはYAMLファイルの書き方をルール化したり、Xcodeのプロジェクト生成を楽にしたりといった工夫が必要です。
そのあたりの具体的な方法が紹介されているので、チームで運用していくことを考えている人は参考になると思います。
プロジェクトファイルからXcodeGenのproject.ymlを生成する
既存のXcodeプロジェクトファイルからproject.yml
を自動生成する機能があり、それについて解説してくれている記事です。
自動生成機能は2021年12月現在でまだDraft PR状態ですが、Draftとはいえかなり使えるものになっているので絶対使ったほうがいいです。
生成されたproject.yml
を見ると、Build Settingsの内容がかなり膨大です。
その中には明示的に指定しなくても良いものも多々あり、上記記事を参考にproject.yml
の記載を減らしていきました。
記事にも記載がありますが、自動生成機能はまだ完全に機能するわけではないものの、ある程度期間が経っているプロジェクトにXcodeGenを導入するなら是非使ってみるべきです。
いきなりproject.yml
を書こうとしても終わりが見えずに途方にくれることになるので、まずは自動生成機能でproject.yml
を生成し、ちょこちょこ直してビルドできる状態まで持っていくところから始めるのがオススメです。
おわりに
私がXcodeGenを導入するにあたりお世話になった記事を3つご紹介しました。
XcodeGenはかなり使われるツールになってきていて発信されている情報は多いので、他にも参考になる記事はたくさんあると思います。
みなさんがお世話になった記事はどれでしょうか?
コメントで教えていただけると嬉しいです!