Swift UIの理解を深めよう

July 24, 2021

SwiftUIの書籍を読破したのでなにか作りたい気持ちなのですが、たまたま見つけたDemystify SwiftUIというビデオの内容が興味深かったのでブログに残してみようと思いました。 動画やプログラムは再頒布禁止なので、もしかするとこの投稿は公開を取り消される可能性があります。 また動画の内容をすべて理解して聴き取れているわけではないので、誤訳等あるかもしれませんのであしからず。


まずはExplicit identityStructural identityがあります。 SwiftUIを理解するにはこの2つの概念を理解する必要があります。

ScrollViewReader { proxy in
  ScrollView {
    HeaderView(article)
      .id(articleId)

    Spacer()

    Button("トップへ") {
      withAnimation {
        proxy.scrollTo(articleId)
      }
    }
  }
}

この例ではarticleIdという明確な識別子を与えることでHeaderViewにジャンプすることが可能ですが、ここにあるScrollViewReaderを始めとするビューには明示的な識別子が存在していないように見えます。 しかし、識別子がないように見えるだけでそれぞれのビューには暗黙的な識別子が存在しています。

次のようなif文による条件式のコードがあるとします:

var body: some View {
  if articles.isEmpty {
    NewArticleForm()
  } else {
    ArticleList()
  }
}

このif文の条件式は_ConditionalContextに展開され、真の場合は常にNewArticleFormを、偽の場合は常にArticleListを返すことでそれぞれのビューに暗黙的な識別子を割り当てています。

some View =
  _ConditionalContext<
    NewArticleForm,
    ArticleList
  >

そのため以下のコードはどちらも動作するのですが、SwiftUIでは同一のビューに同じ識別子を用いるために以下のように書くのが好ましいようです。

// Bad
VStack {
  if article.isPublished {
    ArticleDetail()
      .foregroundColor(Color.blue)
      .background(Color.red)
  } else {
    ArticleDetail()
      .foregroundColor(Color.red)
      .background(Color.blue)
  }
}

// Good
VStack {
  ArticleDetail()
    .foregroundColor(article.isPublished ? Color.blue : Color.red)
    .background(article.isPublished ? Color.red : Color.blue)
}

内部的なヘルパー関数でarticleのタイプによって表示したいビューが分岐する場合はどうでしょうか。 Swiftでは単一の型を返り値として指定する必要があるのでAnyViewを指定する必要があるのですが、Swiftは条件式を最適化することができません。 見た目にもかなりごちゃごちゃしているので次のステップで書き換えます。

func view(for article: Article) -> some View {
  var articleView
  if article.category == .photo {
    articleView = AnyView(PhotoArticleView())
  } else if article.category == .music {
    articleView = AnyView(MusicArticleView())
  } else if article.category == .video {
    articleView = AnyView(VideoArticleView())
    if article.hasThumbnail {
      articleView = AnyView(HStack {
        VideoThumbnail()
        articleView
      })
    }
  } else {
    articleView = AnyView(ArticleView())
  }
  return articleView
}
  1. まずは条件式のうちのビューを単純化します
func view(for article: Article) -> some View {
  var articleView
  if article.category == .photo {
    articleView = AnyView(PhotoArticleView())
  } else if article.category == .music {
    articleView = AnyView(MusicArticleView())
  } else if article.category == .video {
    articleView = AnyView(HStack {
      if article.hasThumbnail {
        VideoThumbnail()
      }
      VideoArticleView()
    })
  } else {
    articleView = AnyView(ArticleView())
  }
  return articleView
}
  1. 返り値が単純になったため、return構文とローカル変数を取り除きます
func view(for article: Article) -> some View {
  if article.category == .photo {
    AnyView(PhotoArticleView())
  } else if article.category == .music {
    AnyView(MusicArticleView())
  } else if article.category == .video {
    AnyView(HStack {
      if article.hasThumbnail {
        VideoThumbnail()
      }
      VideoArticleView()
    })
  } else {
    AnyView(ArticleView())
  }
}
  1. returnを消すと構文エラーになってしまうので、暗黙的に省略されていた@ViewBuilder修飾子を手動で追加し、AnyViewも削除します
@ViewBuilder
func view(for article: Article) -> some View {
  if article.category == .photo {
    PhotoArticleView()
  } else if article.category == .music {
    MusicArticleView()
  } else if article.category == .video {
    HStack {
      if article.hasThumbnail {
        VideoThumbnail()
      }
      VideoArticleView()
    }
  } else {
    ArticleView()
  }
}

上記のコードの型は次のように展開されます:

some View =
  _ConditionalContent<
    _ConditionalContent<
      PhotoArticleView,
      MusicArticleView
    >,
    _ConditionalContent<
      HStack<
        TupleView<(
          VideoThumbnail?,
          VideoArticleView
        )>
      >
      ArticleView
    >
  >

また、上記のコードはさらにswitch文で書き換えることもできます:

@ViewBuilder
func view(for article: Article) -> some View {
  switch article.category {
  case .photo:
    PhotoArticleView()
  case .music:
    MusicArticleView()
  case .video:
    HStack {
      if article.hasThumbnail {
        VideoThumbnail()
      }
      VideoArticleView()
    }
  default:
    ArticleView()
  }
}

続いてビューに使われる値についてです。 この例では同じビューが使われています。

var body: some View {
  if article.isPublished {
    ArticleEdit()
  } else {
    ArticleEdit()
      .contentEditableStyle()
  }
}

上記のビューで条件式が切り替わるたびに同じ初期値("")がメモリ上で確保されます。

struct Article: Identifiable {
  var id: UUID
}

ForEach(articles) { article in
  DetailArticle(article)
}

ビューの値は短命なので、それぞれのビューに任意の識別子を与えることが重要です。 この例ではIdentifiableを指定することで、UUIDを識別子として明示しています。

enum Category { case photo, music }

struct Article: Identifiable {
  var id: UUID { UUID() }
  var category: Category
}

struct ArticleList: View {
  var articles: [Article]
  var body: some View {
    List {
      ForEach(articles) {
        ArticleDetail($0)
      }
    }
  }
}

ただし上記のコードにはバグがあります。 記事(Article)のidUUIDを指定していますが、記事が更新されるたびに画面上すべての記事が更新されてしまいます。 識別子には静的な値を指定する必要があります。

struct ArticleList: View {
  var articles: [Article]
  var body: some View {
    List {
      ForEach(articles.indices, id: \.self) {
        ArticleDetail(articles[$0])
      }
    }
  }
}

このコードはまだ十分ではなく、このままだとインデックスによって内容が変わってしまいます。 例えば先頭に新しい記事が加わり末尾に追加される場合、更新される記事は1つなのに2つの記事が変更されます。

struct ArticleList: View {
  var articles: [Article]
  var body: some View {
    List {
      ForEach(articles.indices, id: \.databaseID) {
        ArticleDetail(articles[$0])
      }
    }
  }
}

この場合の解決策はデータベースに登録されているIDやシリアルナンバーなどの値を識別子として指定することです。 SwiftUIの識別子は開発者が適切な値を指定して管理する必要があります。 上記のことから識別子がユニークであることは非情に重要です。

ForEach(articles, id: \.serialNumber) { article in
  ArticleDetail(article)
    .modifier(ReservedArticleModifier(date: article.publishedAt))
}

struct ReservedArticleModifier: ViewModifier {
  var date: Date
  func body(content: Content) -> some View {
    if date > .now {
      content.opacity(0)
    } else {
      content
    }
  }
}

例えば予約された投稿は透過処理を行うモディファイアがあるとします。 この場合もdateが変わるたびに内容が更新されてしまうようなので、次のように書くとよいみたいです:

struct ReservedArticleModifier: ViewModifier {
  var date: Date
  func body(content: Content) -> some View {
    content.opacity(date > .now ? 0 : 1)
  }
}

この例ではわかりやすい分岐でしたが、ファイル間をまたぐときに思わぬ分岐を生み出す可能性があります。 それを防ぐには不活性モディファイア(Inert Modifier)と呼ばれるパターンを使います:

struct ReservedArticleModifier: ViewModifier {
  func body(content: Content) -> some View {
    content.opacity(1.0)
  }
}

モディファイアの値を変えること自体はさほどコストがかからないため、透過したい箇所にopacity(0)を直接指定できるようにします。 不活性モディファイアは他にもpadding(0)transformEnvironment {}などがあります。


書籍のコードは途中で動かなくなったのともともとの変更が多いのでいきなり最適化に取り組むのは少々大変ですが、まずは単純な条件式でコードを書いたりIdentifiableを利用することを意識するようにしていきたいと思っています。


Profile picture

Personal blog by Seiichi Yonezawa.