スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

iOS バージョン による SwiftUI の機能差分・制限まとめ

こんにちは。iOS エンジニアの wadash です。先月2022年2月に無事リリースした「スタディサプリ 中学講座」のチームで開発に携わっています。

今回は iOS バージョンによる SwiftUI の挙動差異と制限のまとめをご紹介します。

SwiftUI の採用とサポートバージョンについて

スタディサプリ 中学講座」では、SwiftUI を全面的に採用した開発を行っており、アプリのサポートバージョンを iOS 14 以上としています。

プロジェクト発足当初は iOS 13 以上をサポートバージョンにする方針で議論していましたが、プロジェクト期間途中の2020年9月にリリースされた iOS 14 にて、iOS 13 時点で一般に認識されていた SwiftUI の不安定な挙動がある程度改善されたことを確認したため、プロダクト自体の品質向上という目的と、ローンチのタイミングでユーザーがインストールしているであろう iOS のバージョンを考慮してターゲットバージョンを iOS 14 に変更した経緯があります。

結果として、プロダクトの UI の大部分を SwiftUI で高い開発効率をもって実装することが出来ました。また一方で、プロジェクト期間途中には iOS 15 もリリースされており、その対応も考慮する必要もありました。

iOS 14 と iOS 15 の SwiftUI の挙動の差異

SwiftUI で iOS 14, iOS 15 の対応を考慮する上で、Release Note 等の形で Apple から API の変更を告知されている機能はもちろんのこと、実際に開発を進める中で遭遇した不具合に対しても個別に対応をする必要がありました。

ここでは、iOS 14, iOS 15 で新しくサポートされた機能をテーブル形式でまとめ、我々がどのような用途でそれを利用しているのか、あるいは、バージョン差によって生じた不具合についても詳しく紹介していきます。

iOS 14, iOS 15 で新規に追加された SwiftUI の機能

まずはそれぞれのバージョンで新規に追加された機能について紹介します。

iOS 13 から既に対応していた基本的な機能 (例: EnvironmentObject, VStack, Image...) で特に大きな更新が無いものや macOS 等の iOS 以外の OS でのみ対応している機能やその他一部の機能については記載を割愛しています。

また、各機能における Initializer のアップデート(新規追加・非推奨化)についても割愛しています。

カテゴリの分け方は Apple Developer Documentation の SwiftUI の項目から引用しています(2022年3月時点)。

本プロダクトでも活用している機能は多く、一部抜き出して詳細を後述していきます。いかに iOS 14 以降の SwiftUI の機能によってサービスが支えられているかお分かりいただけるかと思います。

[凡例] - : 未対応 / ✅ : 対象 OS バージョンで対応

本プロダクトで特に利用している機能

iOS 14, iOS 15 での追加機能一覧は本稿下部の項目にまとめています。

大項目 中項目 小項目 iOS 14.0+ iOS 15.0+ 備考
App Structure App Structure and Behavior App
UIApplicationDelegateAdaptor
OpenURLAction
Scenes Scene
WindowGroup
State and Data Flow StateObject
EnvironmentValues (後述) N/A N/A
AppStorage
User Interface Elements View Fundamentals View ViewModifiers (後述) N/A N/A
Alert ✅ (Deprecated) ✅ (Deprecated) iOS 13+
ActionSheet ✅ (Deprecated) ✅ (Deprecated) iOS 13+
Text Input and Output ContentSizeCategory ✅ (Deprecated) ✅ (Deprecated) iOS 13+
Layout Containers LazyVStack
LazyHStack
LazyVGrid
GridItem
SafeAreaRegions
ScrollViewReader
ScrollViewProxy
ToolbarItem
ToolbarItemPlacement
ToolbarContent

機能詳細

ここから、本プロダクトで利用している機能や今後導入を検討する可能性のある機能について抜き出してご紹介します。

App / Scene / WindowGroup

従来の AppDelegate, SceneDelegate を利用したライフサイクルとは別に iOS 14 から追加された新しいライフサイクルを実現する機能です。本プロダクトでも採用しています。

以前の記事「サクッとわかる SwiftUI in WWDC 2020」で詳細を解説しています。

StateObject / AppStorage / SceneStorage

それぞれ iOS 14 から追加された SwiftUI のデータ管理に関わる Property Wrapper です。本プロダクトでも採用しています。上記の記事内で同様に解説しています。

DismissAction / DismissSearchAction / RefreshAction

iOS 15 から追加された機能です。dismiss, dismissSearch, refresh の EnvironmentValue で利用でき、関数として各 View や Modifier に紐付いたコールバック内で呼び出すことが出来ます。

それぞれ、遷移後に表示している View を閉じる、検索状態を終了する、View の状態をリフレッシュするための機能を提供します。RefreshAction については、.refreshable(action:) Modifier を利用することで、List 等での Pull-to-refresh を簡単に実装することが出来ます。

iOS 15 以上対応の機能のため、本プロダクトでは現状採用していません。

OpenURLAction

iOS 14 から追加された機能です。openURL の EnvironmentValue で利用でき、引数に URL を持つ関数として呼び出すことが出来ます。本プロダクトでは SwiftUI View から外部ブラウザで URL を開くケースに利用しています。

以下のように、簡単に SwiftUI View 上で URL を開くことが出来ます。

@Environment(\.openURL) private var openURL

var body: some View {
    Button(action: {
        if let url = URL(string: "https://www.example.com") {
            openURL(url)
        }
    }) {
        Text("Open URL")
    }
}
Alert / ActionSheet

SwiftUI が利用可能となった iOS 13 から導入されていた View です。iOS 15 で非推奨となり、これらを利用しない Modifier の alert(_:isPresented:actions:)confirmationDialog(_:isPresented:titleVisibility:actions:) を利用して同様の View を表示することが推奨されています。

// Alert を利用した .alert (iOS 15 以上で非推奨)
SomeView()
    .alert(isPresented: $isAlertPresented) {
        Alert(
            title: Text("Alert Title"),
            dismissButton: .cancel()
        )
    }

// Alert を利用しない .alert (iOS 15 以上で推奨)
SomeView()
    .alert(
        Text("Alert Title"),
        isPresented: $isAlertPresented,
        actions: {
            Button("キャンセル", action: alertAction)
        }
    )
// .actionSheet (iOS 15 以上で非推奨)
SomeView()
    .actionSheet(isPresented: $isActionSheetPresented) {
        ActionSheet(
            title: Text("ActionSheet Title"),
            buttons:[
                .default(Text("変更する"), action: alertAction),
                .cancel()
            ]
        )
    }

// .confirmationDialog (iOS 15 以上で推奨)
SomeView()
    .confirmationDialog(
        Text("Dialog Title"),
        isPresented: $isConfirmationDialogPresented,
        titleVisibility: .visible,
        actions: {
            Button("変更する", action: alertAction)
        }
    )
DynamicTypeSize / ContentSizeCategory

DynamicTypeSizeiOS 15 から追加された機能です。それまで SwiftUI では Dynamic Type の対応には ContentSizeCategory を利用していましたが、それを置き換える形になっています。

サイズの段階に変更はありませんが、名称が現在の Human Interface Guidelines に沿ったものとなっています。

対応する EnvironmentValue も sizeCategory から dynamicTypeSize を利用することが推奨されています。

@Environment(\.sizeCategory) private var sizeCategory // iOS 15 以上で非推奨
@Environment(\.dynamicTypeSize) private var dynamicTypeSize // iOS 15 以上で推奨

var body: some View {
    VStack {
        Text("Dynamic Type Text") // Dynamic Type に対応したサイズ可変の Text
        Text("Dynamic Type Text")
            .environment(\.dynamicTypeSize, .xLarge) // 各 View への固定適用も出来る
    }
}
LazyVStack / LazyHStack

iOS 14 から追加された、レイアウトに関わる Container View です。

それまで、垂直・水平方向のスタックレイアウトを作成するために使われていた VStack / HStack とは異なり、画面上にレンダリングされるタイミングとなって初めてメモリが確保されます。フレームワーク側で自動的に管理してくれるので、実装者は基本的に管理を意識する必要はありません。ScrollView 内に多数の View を表示する必要があるケース等に利用します。

本プロダクトでも、講座一覧やミッション一覧 UI の実装に利用しています。

LazyVGrid / LazyHGrid / GridItem

LazyVStack, LazyHStack 同様に iOS 14 から追加された、レイアウトに関わる View です。

UIKit での UICollectionView のようなレイアウトを実現出来ます。垂直・水平方向のグリッドレイアウトを作成するために利用します。初期化する際、引数の columns or rowsGridItem の Array を渡すことで、画面サイズや Screen Orientation によって変わるフレキシブルなレイアウトを提供出来ます。

本プロダクトでは学習教科一覧の UI 実装に利用しています。

SafeAreaRegions

SafeArea 領域を指定する為の機能です。iOS 14 から追加されました。Modifier ignoresSafeArea(_:edges:) の引数として利用します。

iOS 14 からはキーボード領域が SafeArea 外に変更されており、それ以前に利用されていた edgesIgnoringSafeArea(_:) ではなく、キーボード避けの有無を指定出来るこちらを利用することが推奨されています。

本プロダクトでも View の Background を拡幅する目的や TextField 入力時のキーボード避けの調整の為に Modifier を利用しています。

VStack {
    Spacer()
    TextField("TextField", text: $input)
        .padding()
}
.ignoresSafeArea(.keyboard) // キーボード避けが無効になる
ScrollViewReader / ScrollViewProxy

iOS 14 から追加された機能で、ScrollView においてプログラムによる指定場所へのスクロールを実現します。

id(_:) Modifier を View に適用し、ScrollViewProxy のメソッド scrollTo(_:anchor:) で対象の箇所にまでスクロール位置を移動します。

本プロダクトでも複数の画面で利用している一方で、不具合も確認しているのでそちらについては後述します。

ToolbarItem / ToolbarItemGroup / ToolbarItemPlacement / ToolbarContent / CustomizableToolbarContent

各種メニューボタン等、NavigationBar の Item を表現する方法として、iOS 13 までは navigationBarItems(leading:trailing:) Modifier を利用していましたが、iOS 14 からは代わりに toolbar(content:) を利用することが推奨されています。これにより、ToolBar が表示される位置やグルーピングがしやすくなりました。

FocusState / SubmitTriggers

iOS 15 から導入された、主に TextFieldTextEditor のフォーカス状態の制御と submit ボタンを押した際の挙動をシンプルに制御する為の機能です。

@FocusState Property Wrapper を付与した変数を Enum 等で定義 (ここでは focusedField とします) しておき、各 View 側には focused(_:) Modifier を focusedField に対応した case でそれぞれ指定しておきます。そのように実装しておくことで、focusedField の値が変わるタイミングで別な View にフォーカスが移動します。

また、同じく View 側に onSubmit(of:_:) Modifier を付与しておくことで、ユーザーが各 View 上で submit アクションを取った際に順番にフォーカスを進めることが可能です。

この機能は iOS 15 から追加されているため、現状、本プロダクトでは TextField 関係の機能は UIKit を利用して実装をしています。サポートバージョンを iOS 15 以上に変更とするタイミングで、採用を検討出来ればと考えています。

以上、iOS 14, iOS 15 で新規に追加された SwiftUI の機能の紹介でした。

こうして新規に対応した機能を一覧にすると、今回ローンチしたサービスが iOS 13 ではなく iOS 14 以上をサポートバージョンとして扱うことが出来て良かったと改めて思えますね!

iOS 14 と iOS 15 間で確認された SwiftUI の不具合

以下では、Apple から告知された API の変更情報やバグ情報には無い、我々がプロジェクト内で遭遇した SwiftUI の iOS バージョン差によって発生する不具合の一部をご紹介します。

(iOS 14) View Modifier の body 内で onReceive(_:perform:) が呼ばれない不具合

カスタムな Modifier を定義する際に、その Modifier 定義の body 内 content に onReceive(_:perform:) を指定しても、イベントが受け取れないという不具合を iOS 14 に限定して確認しました。

実装方針の変更があり、結果としてその方法で本プロダクトでは実装することはありませんでしたが、Modifier 内で NotificationCenter を利用したいケース等では注意が必要です。

struct BugModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
                print("Received") // iOS 14 では呼ばれない
            }
    }
}

(iOS 15) ScrollViewProxy における不具合

ScrollViewProxy のメソッド scrollTo(_:anchor:) を利用して指定の位置に画面をスクロールさせる機能において、画面上の View 構造によっては iOS 15 では意図しない位置にスクロールしてしまう不具合を確認しました。 iOS 14 では発生せずに iOS 15 で発生する不具合です。

以下のようなレイアウトを作成した上で、画面上の Button でスクロールをすると、本来であれば 5 が表示される位置で止まるはずが、iOS 15 の場合にはそれを超えてスクロールしてしまいます。

struct ScrollViewProxyBugView: View {
    let scrollId = "5"

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack {
                    Button("Scroll") {
                        withAnimation {
                            proxy.scrollTo(scrollId)
                        }
                    }

                    ForEach(0...9, id: \.self) { number in
                        HStack {
                            Spacer()
                            Text("\(number)")
                                .frame(height: 200)
                                .id("\(number)")
                            Spacer()
                        }
                    }
                }
            }
        }
    }
}

iOS 15 での挙動(不具合)

iOS 14 での挙動(期待動作)

上記のコードでは HStackSpacer を利用しないようにすると発生しないことが確認出来ています。

確認した範囲では画面幅に広がるようなレイアウトにした場合等で発生するようで、今後のアップデートによって改善されることを期待しています。

(iOS 14) statusBar(hidden:) が効かない問題

fullScreenCover を利用して表示する View に対して、StatusBar 非表示の為に statusBar(hidden: true) を適用しても非表示にならない不具合が iOS 14 に限定して確認しました。

struct StatusBarHiddenBugView: View {
    @State private var isCoverPresented = false
    var body: some View {
        Button("Open") {
            isCoverPresented = true
        }
        .fullScreenCover(isPresented: $isCoverPresented) {
            Color.blue
                .statusBar(hidden: true)
        }
    }
}

iOS 14 での挙動(不具合)

iOS 15 での挙動(期待動作)

上記の不具合に対して、現状本プロダクトではデザインを調整して対応しており、サポートバージョン変更のタイミングでの切り替えを検討しています。

以上のように、SwiftUI では不具合という観点でも iOS バージョン間での挙動差異の影響を受けるため、仕様やデザインを吟味する上でも考慮が必要です。

(参考)機能一覧表

本プロダクトで利用していない機能も含めた、iOS 14, iOS 15 から追加された機能の一覧表を掲載します。

App Structure

大項目 中項目 小項目 iOS 14.0+ iOS 15.0+
App Structure and Behavior App
UIApplicationDelegateAdaptor
DismissAction -
DismissSearchAction -
OpenURLAction
RefreshAction -
Scenes Scene
ScenePhase
WindowGroup
App Extension Widget
State and Data Flow StateObject
EnvironmentValues (後述) N/A N/A
AppStorage
SceneStorage
SectionedFetchRequest -
SectionedFetchResults -

User Interface Elements

大項目 中項目 小項目 iOS 14.0+ iOS 15.0+ 備考
View Fundamentals View ViewModifiers (後述) N/A N/A
Alert ✅ (Deprecated) ✅ (Deprecated) iOS 13+
ActionSheet ✅ (Deprecated) ✅ (Deprecated) iOS 13+
Text Input and Output Text textCase()
VoiceOver, Accesibility additonal options -
Label
TextSelectability -
TextEditor
TextInputAutocapitalization -
ScaledMetric
Case
DynamicTypeSize -
ContentSizeCategory ✅ (Deprecated) ✅ (Deprecated) iOS 13+
RedactionReasons
Images AsyncImage -
AsyncImagePhase -
SymbolVariants -
SymbolRenderingMode -
Controls and Indicator Button ButtonRole -
ButtonBorderShape -
Menu
Link
PickerStyle InlinePickerStyle
MenuPickerStyle
DatePickerStyle CompactDatePickerStyle
GraphicalDatePickerStyle
ColorPicker
ProgressView
ControlSize -
Prominence -
Visibility -
Shapes ContainerRelativeShape
HierarchicalShapeStyle -
SelectionShapeStyle -
TintShapeStyle -
AnyShapeStyle -
Material -
BackgroundStyle
EllipticalGradient -
Drawing and Graphics Color brown, cyan, indigo, mint, teal -
GraphicsContext -
ColorMatrix -
MatchedGeometryProperties
Namespace

View Containers

大項目 中項目 小項目 iOS 14.0+ iOS 15.0+
Layout Containers LazyVStack
LazyHStack
PinnedScrollableViews
LazyVGrid
LazyHGrid
GridItem
HorizontalEdge -
VerticalEdge -
SafeAreaRegions
Collection Containers List InsetListStyle
InsetGroupedListStyle
SidebarListStyle
ListItemTint
GroupBox
ControlGroup -
ScrollViewReader
ScrollViewProxy
Presentation Containers NavigationView ColumnNavigationViewStyle -
OutlineGroup
DisclosureGroup
TabView PageTabViewStyle
TimelineView -
TimelineSchedule -
ToolbarItem
ToolbarItemGroup
ToolbarItemPlacement
ToolbarContent
CustomizableToolbarContent
User Input KeyboardShortcut
KeyEquivalent
FocusState -
FocusedBinding
FocusedValue
FocusedValueKey
SubmitTriggers -
SubmitLabel -
ContentShapeKinds -
Previews in Xcode InterfaceOrientation -
Xcode Library Customization LibraryContentProvider

EnvironmentValues

iOS14+ iOS15+ 備考
EnvironmentValues accessibilityLargeContentViewerEnabled -
accessibilityShowButtonShapes
accessibilitySwitchControlEnabled -
accessibilityVoiceOverEnabled -
dismiss -
dismissSearch -
openURL
refresh -
widgetFamily
isFocused
isPresented -
isSearching -
scenePhase
dynamicTypeSize -
textCase
controlSize -
headerProminence -
keyboardShortcut -
menuIndicatorVisibility -
backgroundMaterial -
redactionReasons
symbolRenderingMode -
symbolVariants -
sizeCategory ✅ (Deprecated) ✅ (Deprecated) iOS 13+
presentationMode ✅ (Deprecated) ✅ (Deprecated) iOS 13+

ViewModifiers

iOS14+ iOS15+
ViewModifilers accessibilityLabel
accessibilityInputLabels
accessibilityLabeledPair
accessibilityValue
accessibilityHint
accessibilityActivationPoint
accessibilityChildren -
accessibilityHidden
accessibilityRepresentation -
accessibilityRespondsToUserInteraction -
accessibilityCustomContent -
accessibilityFocused -
accessibilityAddTraits
accessibilityRemoveTraits
accessibilityIdentifier
accessibilityIgnoresInvertColors
accessibilityTextContentType -
accessibilityHeading -
speechAdjustedPitch -
speechAlwaysIncludesPunctuation -
speechAnnouncementsQueued -
speechSpellsOutCharacters -
accessibilityChartDescriptor -
accessibilityShowsLargeContentViewer -
foregroundStyle -
tint -
listRowSeparatorTint -
listItemTint
controlSize -
buttonBorderShape -
headerProminence -
privacySensitive -
redacted
unredacted
menuIndicator -
listRowSeparator -
listSectionSeparator -
dynamicTypeSize -
monospacedDigit -
textCase
textSelection -
textInputAutocapitalization -
symbolRenderingMode -
symbolVariant -
badge -
searchable -
searchCompletion -
navigationTitle
navigationBarTitleDisplayMode
toolbar
help
menuStyle
progressViewStyle
labelStyle
tabViewStyle
controlGroupStyle -
groupBoxStyle
indexViewStyle
scenePadding -
ignoresSafeArea
safeAreaInset -
mask -
containerShape -
animation -
matchedGeometryEffect
handlesExternalEvents
swipeActions -
refreshable -
keyboardShortcut
focused -
focusedValue
focusedSceneValue -
onDrag -
onDrop
onSubmit -
submitScope -
submitLabel -
userActivity
onContinueUserActivity
onChange
task -
onOpenURL
widgetURL
confirmationDialog -
fullScreenCover
interactiveDismissDisabled -
fileExporter
fileImporter
fileMover
quickLookPreview
familyActivityPicker -
musicSubscriptionOffer -
defaultAppStorage

まとめ

以上、主に iOS 14 と iOS 15 の SwiftUI の機能の差異と制限についてご紹介しました。 SwiftUI の導入を考えられている場合のサポートバージョンの検討材料としてお役に立つことがあれば嬉しいです!

We’re hiring!

スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!