0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[SwiftUI] ScrollView × GeometryReader で、自動縮小するカルーセルを作る

Posted at

はじめに

SwiftUIを勉強中です。
使えそうなUIパーツをメモがてら残しておこうと思い、記事としてまとめました。

何がやりたいか

マンガアプリ「マンガワン」にある、
横スクロールする広告部分のような動きをSwiftUIで再現したいと思いました。
著作権の関係でスクショは載せませんが、皆さんもマンガワン使ってみてください。

成果物のサンプル動画

サンプル動画.gif

コード

まずは各パーツのコードを紹介します。
(これ自体はそんなに重要ではない)

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はまだ完全には理解できていないが、やってくうちに慣れていくだろう。

参考にした記事

参考ドキュメント

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?