LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

SwiftUIの導入でLottieと実機とシミュレーターで苦労した話

2021年9月からマッハバイトに業務委託として参画している草間と申します。
iOSエンジニアとして参画し、今ではandroid・Web側も担当するようになり楽しく業務しています。

前回は導入した話をしました。
今回は、SwiftUIの導入で苦労した話をします。

OSSの動きが想定と違った問題

アニメーションのViewが画面サイズより大きくなる

マッハバイトiOSアプリでは、アニメーションの実装にLottieを利用しています。
SwiftUIからLottieのViewを利用できるように次の実装しました。

import Lottie
import SwiftUI

struct LottieView: UIViewRepresentable {

    private let lottieView = LottieAnimationView()

    func makeUIView(context: Context) -> LottieAnimationView {
        lottieView
    }

    func updateUIView(_ uiView: LottieAnimationView, context: Context) {
        ....
    }
}

前回話したUIViewをSwiftUIで利用するUIViewRepresentableを利用してLottieのViewをそのまま返す実装です。
この実装の場合、LottieのViewが画面サイズに収まらずだいたい倍くらいの大きさで表現されてしまいます。
そこでLottieのViewをそのまま返さずサイズのベースになるUIViewを利用する形に変えました。

import Lottie
import SwiftUI
import UIKit

struct LottieView: UIViewRepresentable {

    private let lottieView = LottieAnimationView()
    private let baseView = UIView()

    init(animationResourceName: String) {
        lottieView.animation = LottieAnimation.named(animationResourceName)
        lottieView.contentMode = .scaleAspectFit
        baseView.addSubview(lottieView)
        lottieView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            baseView.topAnchor.constraint(equalTo: lottieView.topAnchor),
            baseView.leadingAnchor.constraint(equalTo: lottieView.leadingAnchor),
            baseView.trailingAnchor.constraint(equalTo: lottieView.trailingAnchor),
            baseView.bottomAnchor.constraint(equalTo: lottieView.bottomAnchor)
        ])
        lottieView.updateConstraints()
    }

    func makeUIView(context: Context) -> UIView {
        baseView
    }

    func updateUIView(_ uiView: UIView, context: Context) {
    }
}

この実装でSwiftUI上でUIViewの大きさが画面サイズまでで制限され、LottieのViewはUIViewの大きさに制約がかかるため、画面サイズを超えないようにできました。

画面サイズの違いで発生した問題

scaledToFitだけでは画像サイズがよしなに広がってくれない?

前回、マッハバイトiOSアプリの静的な画面をSwiftUIに置き換えた話をしました。
置き換えた画面には上部にヘッダー画像があります。
実装当時の理解では、Imageオブジェクトは上下左右にpaddingやmarge等入れなければ
親のViewに対していい感じに広がりscaledToFitでよしなに表示してくれると思っていました。 (下の画像のように)

ところが実行してみると次のコードでは下の画像のようになりました。

Image("TestTop")
    .scaledToFit()


見事に左右に隙間が発生しました。
画像をベクターで用意していなかったこともあると思いますが、画像をImageオブジェクトに合わせてリサイズする必要がありました。

Image("TestTop")
    .resizable()
    .frame(maxWidth: .infinity)
    .scaledToFit()

上記のように変更することで思っていたように表現できました。
コードとしては.resizable()でリサイズされるようにし、.frame(maxWidth: .infinity)で横幅を親オブジェクトの横幅いっぱいに広がるように設定しています。

実機でしか発生しなかった問題

文字が省略されてる!?

画像サイズの違いで発生した問題が解決すると次はちゃんと全文表示されていたはずのテキストが省略表示になっていました。

画像の対応を入れる前は全文表示されていたはずなのに。。。と汗をかくくらい焦りました。
実装時はどうして省略されてしまっているのかを調べず対策方法を探しました。
対策としては.fixedSize(horizontal: false, vertical: true)を実装することでした。

Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
    .foregroundColor(.white)
    .background(Color.gray)
    .fixedSize(horizontal: false, vertical: true)
    .modifier(BorderTextModifier())

こうすることで次の画像のように全文表示されるようになります。

検証したこと

SwiftUIに置き換えたアプリをリリースした後になぜ問題が発生したのかを軽く検証してみました。
検証する上でXIBからSwiftUIを利用しているから発生したのではないかと仮説を立てました。

検証環境としてプロジェクトを3つ用意

仮説を検証するため次の3つのプロジェクトを作成しました。

  • SwiftUIオンリーのプロジェクト
  • StoryboardからSwiftUIを表示するプロジェクト
  • XIBからSwiftUIを表示するプロジェクト

結論からいうとXIBからSwiftUIを利用しているから発生したわけではなかったです。

文字が省略されてしまう条件は?

サンプルプロジェクトで次のように実装して検証してみました。

struct SampleSwiftUI: View {
    var body: some View {
        ZStack {
            Color.gray
                .ignoresSafeArea()

            ScrollView {
                VStack {
                    Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                        .foregroundColor(.white)
                        .background(Color.gray)
                        .modifier(BorderTextModifier())

                    VStack(alignment: .trailing, spacing: 10)  {
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())
                        
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())
                        
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())
                        
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())
                        
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())
                    }.padding(.horizontal, 16)
                    
                    Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                        .foregroundColor(.white)
                        .background(Color.gray)
                        .modifier(BorderTextModifier())
                }
            }
        }
    }
    
    private struct BorderTextModifier: ViewModifier {

        func body(content: Content) -> some View {
            content
                .frame(maxWidth: .infinity, alignment: .leading)
                .font(.system(size: 12))
                .padding(.all, 16)
                .lineSpacing(4)
                .overlay(
                    RoundedRectangle(cornerRadius: 6)
                        .stroke(.black, lineWidth: 1)
                )
        }
    }
}

struct SampleSwiftUI_Previews: PreviewProvider {
    static var previews: some View {
        SampleSwiftUI()
    }
}

ViewModifierについてはAppendixで話します。

この場合では文字列は省略されず全文表示されていました。
文字列のみの場合は省略されないことがわかったので実実装同様に上部にヘッダー画像を入れることにしました。

ScrollView {
    VStack {

    Image("TestTop")
    ....
}

上記のように画像だけ入れてみました。
この場合でも文字列は省略されず全文表示されていました。
ここまでくるとおわかりいただけると思いますが、.resizable()をした結果省略されることがわかりました。
ただし、.resizable()だけで省略されるわけではありません。
上のコードにImageを追加して確認しました。

ScrollView {
    VStack {
        // これを追加
        Image("TestTop")
            .resizable()
            .frame(maxWidth: .infinity)
            .scaledToFit()

すると一部だけ省略される現象が発生しました。
コードとしては次の部分です。

struct SampleSwiftUI: View {
    var body: some View {
        ZStack {
            Color.gray
                .ignoresSafeArea()

            ScrollView {
                VStack {
                    
                    Image("TestTop")
                        .resizable()
                        .frame(maxWidth: .infinity)
                        .scaledToFit()

                    VStack(alignment: .trailing, spacing: 10)  {
                        // ここのText部分
                        Text("てすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすとてすと")
                            .foregroundColor(.white)
                            .background(Color.gray)
                            .modifier(BorderTextModifier())

Imageと同じ階層に実装しているTextでは省略が発生せず、VStackの中に入れて階層を変えたTextで発生することがわかりました。
実装を見ていただけるとわかりますが、省略が発生するVStack内にはTextが複数実装してあります。
では、VStackに1個のみTextを実装した場合はどうなるかというと、省略されることはありません。
次に気になったのは何個までなら省略されないのかでした。
結論としては、VStack内に2つまでは省略されないです。

※画像に関しては僕の知識不足なところが否めないので割愛します。
※Lottieについてはサンプルの用意が難しかったため検証していません。

まとめ

検証した結果次のことがわかりました。

  • Imageのリサイズと同階層のVStack内のTextは省略される場合がある

今回検証したプロジェクトはGitHubにあげています。
ここで紹介したコードにHStackを加えてみたりしています。
まだまだ検証パターンが足りないかもしれないので、別のパターンも試してみたいときなどにご活用いただければ幸いです。

最後に

iOSエンジニアの方ならいつものことだと思いますが、シミュレーターではきれいに表示されます。
昔からこういうことは多発していたのでちゃんと実機でも確認しましょうというのが僕の今回の反省事項でした。

少しでも誰かの苦労解決の役にたてば幸いです。

Appendix

検証したことで紹介したコードで前回話をしていないものがあったのでこちらで話をします。

ViewModifier

ViewModifierというプロトコルを継承した構造体を利用してTextの属性を制御しています。
このViewModifierを簡単に説明するならCSSのようなものというのがイメージしやすいかなと思います。

private struct BorderTextModifier: ViewModifier {

    func body(content: Content) -> some View {
        content
            .frame(maxWidth: .infinity, alignment: .leading)
            .font(.system(size: 12))
            .padding(.all, 16)
            .lineSpacing(4)
            .overlay(
                RoundedRectangle(cornerRadius: 6)
                    .stroke(.black, lineWidth: 1)
            )
    }
}

上記のように実装します。
この構造体を次のように使うと設定している属性が反映されます。

Text("テスト")
    .modifier(BorderTextModifier())

利点は何度も同じものを記述しなくて済むところにあります。