はじめに
SwiftUIを勉強中です。
使えそうなUIパーツをメモがてら残しておこうと思い、記事としてまとめました。
何がやりたいか
マンガアプリ「マンガワン」にある、
横スクロールする広告部分のような動きをSwiftUIで再現したいと思いました。
著作権の関係でスクショは載せませんが、皆さんもマンガワン使ってみてください。
成果物のサンプル動画
コード
まずは各パーツのコードを紹介します。
(これ自体はそんなに重要ではない)
ThumbnailView.swift
ThumbnailView.swift
import SwiftUI
struct ThumbnailView: View {
var body: some View {
Rectangle()
.foregroundColor(.blue)
.frame(
width: UIConstants.Thumbnail.width,
height: UIConstants.Thumbnail.height
)
.cornerRadius(UIConstants.Thumbnail.cornerRadius)
}
}
DetailView.swift
DetailView.swift
import SwiftUI
struct DetailView: View {
var body: some View {
ZStack {
Rectangle()
.foregroundStyle(Color.red)
.frame(
width: UIConstants.Detail.width,
height: UIConstants.Detail.height
)
.clipShape(
.rect(
topLeadingRadius: 0,
bottomLeadingRadius: UIConstants.Detail.cornerRadius,
bottomTrailingRadius: UIConstants.Detail.cornerRadius,
topTrailingRadius: 0
)
)
HStack() {
Text("1~2巻無料!")
.foregroundStyle(Color.white)
.font(
.system(
size: 18,
weight: .bold
)
)
Spacer(minLength: 0)
Text("10/23まで")
.foregroundStyle(Color.white)
.font(
.system(
size: 12,
weight: .medium
)
)
}
.padding(.horizontal, 8)
.frame(
width: UIConstants.Detail.width,
height: UIConstants.Detail.height
)
}
}
}
UIConstants.swift(各パーツのレイアウト指定用)
UIConstants.swift
import SwiftUI
enum UIConstants {
enum Thumbnail {
static let width: CGFloat = 180
static let height: CGFloat = 320
static let cornerRadius: CGFloat = 12
}
enum Detail {
static let width: CGFloat = 180
static let height: CGFloat = 40
static let cornerRadius: CGFloat = 12
}
}
実際に動かしている部分
ContentView.swift
ContentView.swift
import SwiftUI
internal import Combine
struct ContentView: View {
@State private var scrollPosition: Int? = 0
private let carouselRange = 10
var body: some View {
/// 3秒ごとの自動発火タイマー
let timer = Timer.publish(
every: 3.0,
on: .main,
in: .common
).autoconnect()
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(0..<carouselRange) { index in
// 中心から離れるほど拡大率が下がるよう指定
GeometryReader { geometry in
let midX = geometry.frame(in: .global).midX
let halfSize = screenWidht() / 2
let distance = abs(midX - halfSize)
let scale = max(0.68, 1 - distance / 500)
ZStack(alignment: .bottom) {
ThumbnailView()
DetailView()
}
.scaleEffect(scale)
}
// ScrollViewによるページングのスクロール量をGeometryReaderのサイズと同じになるよう指定
.containerRelativeFrame(.horizontal)
.id(index)
}
}
.scrollTargetLayout()
.onReceive(timer) { _ in
guard let scrollPosition else { return }
// タイマーの発火タイミングに合わせて自動ページングするよう設定
let carouselCount = carouselRange
let carouselMaxIndex = carouselCount - 1
withAnimation {
if carouselMaxIndex <= scrollPosition {
// 先頭に戻る
self.scrollPosition = 0
} else {
// 次のページへ移動
self.scrollPosition = scrollPosition + 1
}
}
}
}
.scrollPosition(id: $scrollPosition)
.scrollTargetBehavior(.viewAligned)
.safeAreaPadding(.horizontal, scrollPadding())
}
func screenWidht() -> CGFloat {
guard let window = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return .zero }
return window.screen.bounds.width
}
func scrollPadding() -> CGFloat {
let padding = (screenWidht() - UIConstants.Thumbnail.width) / 2
return padding
}
}
重要な部分
-
.scrollTargetLayout()
- スクロールターゲットとして要素を認識させるためのモディファイア
- 今回は GeometryReader をターゲットとして利用
-
.scrollTargetBehavior(.viewAligned)
- スクロール挙動を指定するモディファイア
- .pagingを指定すると、ScrollViewの大きさ分のスクロールが行われる
- 細かい指定をするなら、.viewAlignedがオススメ
-
GeometryReader
- 親ビューに対する子ビューの位置やサイズを取得できる
- これを利用し、親View(ScrollView)と子View(GeometryReader)の位置を比較してサイズが変わるよう設定している
- サイズを固定したい場合はGeometryReaderは不要
作ってみた感想
iOSのバージョン制約はあるものの、かなり少ない手順で作れて良かった。
GeometryReaderはまだ完全には理解できていないが、やってくうちに慣れていくだろう。
参考にした記事
参考ドキュメント
