├── .gitattributes ├── .gitignore ├── LICENSE.md ├── README.md ├── ViewLifecycle.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── ViewLifecycle ├── App.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── CaseStudies.swift ├── CaseStudyIDModifier.swift ├── CaseStudyIfElse.swift ├── CaseStudyLazyVGrid.swift ├── CaseStudyLazyVStack.swift ├── CaseStudyListDynamic.swift ├── CaseStudyListStatic.swift ├── CaseStudyNavigationStack.swift ├── CaseStudyOpacity.swift ├── CaseStudyScrollViewDynamic.swift ├── CaseStudyScrollViewStatic.swift ├── CaseStudySwitch.swift ├── CaseStudyTabView.swift ├── Entitlements.entitlements ├── LifecycleMonitor.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── RootView.swift └── SampleModel.swift └── assets ├── LifecycleMonitor-example.png ├── ios-collage.png ├── ipad-tabview.png └── mac-list-dynamic.png /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj merge=union 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Xcode 5 | xcuserdata/ 6 | 7 | # SwiftPM 8 | .build/ 9 | 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2022 Ole Begemann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI View Lifecycle 2 | 3 | An iOS and macOS app that demonstrates how different SwiftUI constructs and SwiftUI container views affect: 4 | 5 | - the lifetime of `@State` 6 | - the firing of events such as `onAppear` and `onDisappear` 7 | 8 | By [Ole Begemann](https://oleb.net), 2022 9 | 10 | [SwiftUI View Lifecycle app on GitHub](https://github.com/ole/swiftui-view-lifecycle>)
11 | My article introducing the app: [Understanding View Lifecycles (2022-12-15)](https://oleb.net/2022/swiftui-view-lifecycle/) 12 | 13 | ## Usage 14 | 15 | 1. Open the project in Xcode. 16 | 2. Run the app on the iOS simulator, an iOS device, or on macOS. 17 | 3. Click through the list of examples and observe the timestamps when certain lifecycle events happened. 18 | 19 | ## Requirements 20 | 21 | Requires iOS 16 or macOS 13. 22 | 23 | ## Screenshots 24 | 25 | ### iPhone 26 | 27 | 28 | 29 | ### iPad 30 | 31 | 32 | 33 | ### Mac 34 | 35 | 36 | 37 | ## The `LifecycleMonitor` view 38 | 39 | All examples use one or more [`LifecycleMonitor`](https://github.com/ole/swiftui-view-lifecycle/blob/main/ViewLifecycle/LifecycleMonitor.swift) views as their content. The view below tracks its lifecycle events and displays them as constantly-updating timestamps. For example, this view got created 1:26 minutes ago, which is also when its `@State` got created. Its `.onAppear` and `.onDisappear` actions were last called 15 and 47 seconds ago, respectively: 40 | 41 | 42 | 43 | As you interact with the app, e.g. by scrolling through a `List`, you’ll see these timestamps update (or not, depending on the container view). Pay special attention to resets of the `@State` field because this means that the view got destroyed and recreated, losing all of its internal state. 44 | 45 | The view’s background color is set to a random color when its `@State` is created, so color changes are another indication that the view identity has changed. 46 | 47 | ## License 48 | 49 | [MIT license](LICENSE.md) 50 | -------------------------------------------------------------------------------- /ViewLifecycle.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 5D0520B72934DC330080E4C0 /* CaseStudyListStatic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0520B62934DC330080E4C0 /* CaseStudyListStatic.swift */; }; 11 | 5D0520B92934E6130080E4C0 /* CaseStudyLazyVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0520B82934E6130080E4C0 /* CaseStudyLazyVGrid.swift */; }; 12 | 5D305D20293240F300E42700 /* LifecycleMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D1F293240F300E42700 /* LifecycleMonitor.swift */; }; 13 | 5D305D222932444500E42700 /* CaseStudyScrollViewStatic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D212932444500E42700 /* CaseStudyScrollViewStatic.swift */; }; 14 | 5D305D242932547F00E42700 /* CaseStudyListDynamic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D232932547F00E42700 /* CaseStudyListDynamic.swift */; }; 15 | 5D305D26293260BE00E42700 /* CaseStudyScrollViewDynamic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D25293260BE00E42700 /* CaseStudyScrollViewDynamic.swift */; }; 16 | 5D305D28293260EC00E42700 /* SampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D27293260EC00E42700 /* SampleModel.swift */; }; 17 | 5D305D2A293266D000E42700 /* CaseStudyTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D305D29293266D000E42700 /* CaseStudyTabView.swift */; }; 18 | 5D60F905293FA5F400AE0726 /* CaseStudies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D60F904293FA5F400AE0726 /* CaseStudies.swift */; }; 19 | 5D60F907293FA8D400AE0726 /* CaseStudyIfElse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D60F906293FA8D400AE0726 /* CaseStudyIfElse.swift */; }; 20 | 5D60F93F293FBBE800AE0726 /* CaseStudyNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D60F93E293FBBE800AE0726 /* CaseStudyNavigationStack.swift */; }; 21 | 5D6C3F6C2908541100A0C864 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C3F6B2908541100A0C864 /* App.swift */; }; 22 | 5D6C3F6E2908541100A0C864 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C3F6D2908541100A0C864 /* RootView.swift */; }; 23 | 5D6C3F702908541200A0C864 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C3F6F2908541200A0C864 /* Assets.xcassets */; }; 24 | 5D6C3F742908541200A0C864 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C3F732908541200A0C864 /* Preview Assets.xcassets */; }; 25 | 5D98162D294796AE00EA886F /* CaseStudyIDModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D98162C294796AE00EA886F /* CaseStudyIDModifier.swift */; }; 26 | 5DA368472941063B00E91959 /* CaseStudySwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DA368462941063B00E91959 /* CaseStudySwitch.swift */; }; 27 | 5DEE8421294A2A3A00BD2649 /* CaseStudyLazyVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DEE8420294A2A3A00BD2649 /* CaseStudyLazyVStack.swift */; }; 28 | 5DEE8423294A2BA700BD2649 /* CaseStudyOpacity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DEE8422294A2BA700BD2649 /* CaseStudyOpacity.swift */; }; 29 | /* End PBXBuildFile section */ 30 | 31 | /* Begin PBXFileReference section */ 32 | 5D0520B62934DC330080E4C0 /* CaseStudyListStatic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyListStatic.swift; sourceTree = ""; }; 33 | 5D0520B82934E6130080E4C0 /* CaseStudyLazyVGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyLazyVGrid.swift; sourceTree = ""; }; 34 | 5D305D1F293240F300E42700 /* LifecycleMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleMonitor.swift; sourceTree = ""; }; 35 | 5D305D212932444500E42700 /* CaseStudyScrollViewStatic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyScrollViewStatic.swift; sourceTree = ""; }; 36 | 5D305D232932547F00E42700 /* CaseStudyListDynamic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyListDynamic.swift; sourceTree = ""; }; 37 | 5D305D25293260BE00E42700 /* CaseStudyScrollViewDynamic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseStudyScrollViewDynamic.swift; sourceTree = ""; }; 38 | 5D305D27293260EC00E42700 /* SampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleModel.swift; sourceTree = ""; }; 39 | 5D305D29293266D000E42700 /* CaseStudyTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyTabView.swift; sourceTree = ""; }; 40 | 5D60F904293FA5F400AE0726 /* CaseStudies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudies.swift; sourceTree = ""; }; 41 | 5D60F906293FA8D400AE0726 /* CaseStudyIfElse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyIfElse.swift; sourceTree = ""; }; 42 | 5D60F93E293FBBE800AE0726 /* CaseStudyNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyNavigationStack.swift; sourceTree = ""; }; 43 | 5D6C3F682908541100A0C864 /* ViewLifecycle.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewLifecycle.app; sourceTree = BUILT_PRODUCTS_DIR; }; 44 | 5D6C3F6B2908541100A0C864 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 45 | 5D6C3F6D2908541100A0C864 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 46 | 5D6C3F6F2908541200A0C864 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47 | 5D6C3F712908541200A0C864 /* Entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.entitlements; sourceTree = ""; }; 48 | 5D6C3F732908541200A0C864 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 49 | 5D98162C294796AE00EA886F /* CaseStudyIDModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyIDModifier.swift; sourceTree = ""; }; 50 | 5DA368462941063B00E91959 /* CaseStudySwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudySwitch.swift; sourceTree = ""; }; 51 | 5DA368482941082900E91959 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; 52 | 5DA36849294108CA00E91959 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 53 | 5DEE8420294A2A3A00BD2649 /* CaseStudyLazyVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyLazyVStack.swift; sourceTree = ""; }; 54 | 5DEE8422294A2BA700BD2649 /* CaseStudyOpacity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaseStudyOpacity.swift; sourceTree = ""; }; 55 | /* End PBXFileReference section */ 56 | 57 | /* Begin PBXFrameworksBuildPhase section */ 58 | 5D6C3F652908541100A0C864 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | /* End PBXFrameworksBuildPhase section */ 66 | 67 | /* Begin PBXGroup section */ 68 | 5D6C3F5F2908541100A0C864 = { 69 | isa = PBXGroup; 70 | children = ( 71 | 5DA36849294108CA00E91959 /* README.md */, 72 | 5DA368482941082900E91959 /* LICENSE.md */, 73 | 5D6C3F6A2908541100A0C864 /* ViewLifecycle */, 74 | 5D6C3F692908541100A0C864 /* Products */, 75 | ); 76 | sourceTree = ""; 77 | }; 78 | 5D6C3F692908541100A0C864 /* Products */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | 5D6C3F682908541100A0C864 /* ViewLifecycle.app */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | 5D6C3F6A2908541100A0C864 /* ViewLifecycle */ = { 87 | isa = PBXGroup; 88 | children = ( 89 | 5D6C3F6B2908541100A0C864 /* App.swift */, 90 | 5D60F904293FA5F400AE0726 /* CaseStudies.swift */, 91 | 5D98162C294796AE00EA886F /* CaseStudyIDModifier.swift */, 92 | 5D60F906293FA8D400AE0726 /* CaseStudyIfElse.swift */, 93 | 5D0520B82934E6130080E4C0 /* CaseStudyLazyVGrid.swift */, 94 | 5DEE8420294A2A3A00BD2649 /* CaseStudyLazyVStack.swift */, 95 | 5D305D232932547F00E42700 /* CaseStudyListDynamic.swift */, 96 | 5D0520B62934DC330080E4C0 /* CaseStudyListStatic.swift */, 97 | 5D60F93E293FBBE800AE0726 /* CaseStudyNavigationStack.swift */, 98 | 5DEE8422294A2BA700BD2649 /* CaseStudyOpacity.swift */, 99 | 5D305D25293260BE00E42700 /* CaseStudyScrollViewDynamic.swift */, 100 | 5D305D212932444500E42700 /* CaseStudyScrollViewStatic.swift */, 101 | 5DA368462941063B00E91959 /* CaseStudySwitch.swift */, 102 | 5D305D29293266D000E42700 /* CaseStudyTabView.swift */, 103 | 5D305D1F293240F300E42700 /* LifecycleMonitor.swift */, 104 | 5D6C3F6D2908541100A0C864 /* RootView.swift */, 105 | 5D305D27293260EC00E42700 /* SampleModel.swift */, 106 | 5D6C3F6F2908541200A0C864 /* Assets.xcassets */, 107 | 5D6C3F712908541200A0C864 /* Entitlements.entitlements */, 108 | 5D6C3F722908541200A0C864 /* Preview Content */, 109 | ); 110 | path = ViewLifecycle; 111 | sourceTree = ""; 112 | }; 113 | 5D6C3F722908541200A0C864 /* Preview Content */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | 5D6C3F732908541200A0C864 /* Preview Assets.xcassets */, 117 | ); 118 | path = "Preview Content"; 119 | sourceTree = ""; 120 | }; 121 | /* End PBXGroup section */ 122 | 123 | /* Begin PBXNativeTarget section */ 124 | 5D6C3F672908541100A0C864 /* ViewLifecycle */ = { 125 | isa = PBXNativeTarget; 126 | buildConfigurationList = 5D6C3F772908541200A0C864 /* Build configuration list for PBXNativeTarget "ViewLifecycle" */; 127 | buildPhases = ( 128 | 5D6C3F642908541100A0C864 /* Sources */, 129 | 5D6C3F652908541100A0C864 /* Frameworks */, 130 | 5D6C3F662908541100A0C864 /* Resources */, 131 | ); 132 | buildRules = ( 133 | ); 134 | dependencies = ( 135 | ); 136 | name = ViewLifecycle; 137 | productName = OnAppearInScrollView; 138 | productReference = 5D6C3F682908541100A0C864 /* ViewLifecycle.app */; 139 | productType = "com.apple.product-type.application"; 140 | }; 141 | /* End PBXNativeTarget section */ 142 | 143 | /* Begin PBXProject section */ 144 | 5D6C3F602908541100A0C864 /* Project object */ = { 145 | isa = PBXProject; 146 | attributes = { 147 | BuildIndependentTargetsInParallel = 1; 148 | LastSwiftUpdateCheck = 1410; 149 | LastUpgradeCheck = 1420; 150 | TargetAttributes = { 151 | 5D6C3F672908541100A0C864 = { 152 | CreatedOnToolsVersion = 14.1; 153 | }; 154 | }; 155 | }; 156 | buildConfigurationList = 5D6C3F632908541100A0C864 /* Build configuration list for PBXProject "ViewLifecycle" */; 157 | compatibilityVersion = "Xcode 14.0"; 158 | developmentRegion = en; 159 | hasScannedForEncodings = 0; 160 | knownRegions = ( 161 | en, 162 | Base, 163 | ); 164 | mainGroup = 5D6C3F5F2908541100A0C864; 165 | productRefGroup = 5D6C3F692908541100A0C864 /* Products */; 166 | projectDirPath = ""; 167 | projectRoot = ""; 168 | targets = ( 169 | 5D6C3F672908541100A0C864 /* ViewLifecycle */, 170 | ); 171 | }; 172 | /* End PBXProject section */ 173 | 174 | /* Begin PBXResourcesBuildPhase section */ 175 | 5D6C3F662908541100A0C864 /* Resources */ = { 176 | isa = PBXResourcesBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | 5D6C3F742908541200A0C864 /* Preview Assets.xcassets in Resources */, 180 | 5D6C3F702908541200A0C864 /* Assets.xcassets in Resources */, 181 | ); 182 | runOnlyForDeploymentPostprocessing = 0; 183 | }; 184 | /* End PBXResourcesBuildPhase section */ 185 | 186 | /* Begin PBXSourcesBuildPhase section */ 187 | 5D6C3F642908541100A0C864 /* Sources */ = { 188 | isa = PBXSourcesBuildPhase; 189 | buildActionMask = 2147483647; 190 | files = ( 191 | 5D0520B92934E6130080E4C0 /* CaseStudyLazyVGrid.swift in Sources */, 192 | 5DEE8423294A2BA700BD2649 /* CaseStudyOpacity.swift in Sources */, 193 | 5D0520B72934DC330080E4C0 /* CaseStudyListStatic.swift in Sources */, 194 | 5D305D2A293266D000E42700 /* CaseStudyTabView.swift in Sources */, 195 | 5D305D28293260EC00E42700 /* SampleModel.swift in Sources */, 196 | 5D305D26293260BE00E42700 /* CaseStudyScrollViewDynamic.swift in Sources */, 197 | 5DEE8421294A2A3A00BD2649 /* CaseStudyLazyVStack.swift in Sources */, 198 | 5D60F93F293FBBE800AE0726 /* CaseStudyNavigationStack.swift in Sources */, 199 | 5D98162D294796AE00EA886F /* CaseStudyIDModifier.swift in Sources */, 200 | 5D60F907293FA8D400AE0726 /* CaseStudyIfElse.swift in Sources */, 201 | 5DA368472941063B00E91959 /* CaseStudySwitch.swift in Sources */, 202 | 5D305D222932444500E42700 /* CaseStudyScrollViewStatic.swift in Sources */, 203 | 5D305D242932547F00E42700 /* CaseStudyListDynamic.swift in Sources */, 204 | 5D305D20293240F300E42700 /* LifecycleMonitor.swift in Sources */, 205 | 5D6C3F6E2908541100A0C864 /* RootView.swift in Sources */, 206 | 5D60F905293FA5F400AE0726 /* CaseStudies.swift in Sources */, 207 | 5D6C3F6C2908541100A0C864 /* App.swift in Sources */, 208 | ); 209 | runOnlyForDeploymentPostprocessing = 0; 210 | }; 211 | /* End PBXSourcesBuildPhase section */ 212 | 213 | /* Begin XCBuildConfiguration section */ 214 | 5D6C3F752908541200A0C864 /* Debug */ = { 215 | isa = XCBuildConfiguration; 216 | buildSettings = { 217 | ALWAYS_SEARCH_USER_PATHS = NO; 218 | CLANG_ANALYZER_NONNULL = YES; 219 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 220 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 221 | CLANG_ENABLE_MODULES = YES; 222 | CLANG_ENABLE_OBJC_ARC = YES; 223 | CLANG_ENABLE_OBJC_WEAK = YES; 224 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 225 | CLANG_WARN_BOOL_CONVERSION = YES; 226 | CLANG_WARN_COMMA = YES; 227 | CLANG_WARN_CONSTANT_CONVERSION = YES; 228 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 229 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 230 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 231 | CLANG_WARN_EMPTY_BODY = YES; 232 | CLANG_WARN_ENUM_CONVERSION = YES; 233 | CLANG_WARN_INFINITE_RECURSION = YES; 234 | CLANG_WARN_INT_CONVERSION = YES; 235 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 236 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 237 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 238 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 239 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 240 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 241 | CLANG_WARN_STRICT_PROTOTYPES = YES; 242 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 243 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 244 | CLANG_WARN_UNREACHABLE_CODE = YES; 245 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 246 | COPY_PHASE_STRIP = NO; 247 | DEAD_CODE_STRIPPING = YES; 248 | DEBUG_INFORMATION_FORMAT = dwarf; 249 | ENABLE_STRICT_OBJC_MSGSEND = YES; 250 | ENABLE_TESTABILITY = YES; 251 | GCC_C_LANGUAGE_STANDARD = gnu11; 252 | GCC_DYNAMIC_NO_PIC = NO; 253 | GCC_NO_COMMON_BLOCKS = YES; 254 | GCC_OPTIMIZATION_LEVEL = 0; 255 | GCC_PREPROCESSOR_DEFINITIONS = ( 256 | "DEBUG=1", 257 | "$(inherited)", 258 | ); 259 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 260 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 261 | GCC_WARN_UNDECLARED_SELECTOR = YES; 262 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 263 | GCC_WARN_UNUSED_FUNCTION = YES; 264 | GCC_WARN_UNUSED_VARIABLE = YES; 265 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 266 | MTL_FAST_MATH = YES; 267 | ONLY_ACTIVE_ARCH = YES; 268 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 269 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 270 | }; 271 | name = Debug; 272 | }; 273 | 5D6C3F762908541200A0C864 /* Release */ = { 274 | isa = XCBuildConfiguration; 275 | buildSettings = { 276 | ALWAYS_SEARCH_USER_PATHS = NO; 277 | CLANG_ANALYZER_NONNULL = YES; 278 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 279 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 280 | CLANG_ENABLE_MODULES = YES; 281 | CLANG_ENABLE_OBJC_ARC = YES; 282 | CLANG_ENABLE_OBJC_WEAK = YES; 283 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 284 | CLANG_WARN_BOOL_CONVERSION = YES; 285 | CLANG_WARN_COMMA = YES; 286 | CLANG_WARN_CONSTANT_CONVERSION = YES; 287 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 288 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 289 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 290 | CLANG_WARN_EMPTY_BODY = YES; 291 | CLANG_WARN_ENUM_CONVERSION = YES; 292 | CLANG_WARN_INFINITE_RECURSION = YES; 293 | CLANG_WARN_INT_CONVERSION = YES; 294 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 295 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 296 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 297 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 298 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 299 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 300 | CLANG_WARN_STRICT_PROTOTYPES = YES; 301 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 302 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 303 | CLANG_WARN_UNREACHABLE_CODE = YES; 304 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 305 | COPY_PHASE_STRIP = NO; 306 | DEAD_CODE_STRIPPING = YES; 307 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 308 | ENABLE_NS_ASSERTIONS = NO; 309 | ENABLE_STRICT_OBJC_MSGSEND = YES; 310 | GCC_C_LANGUAGE_STANDARD = gnu11; 311 | GCC_NO_COMMON_BLOCKS = YES; 312 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 313 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 314 | GCC_WARN_UNDECLARED_SELECTOR = YES; 315 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 316 | GCC_WARN_UNUSED_FUNCTION = YES; 317 | GCC_WARN_UNUSED_VARIABLE = YES; 318 | MTL_ENABLE_DEBUG_INFO = NO; 319 | MTL_FAST_MATH = YES; 320 | SWIFT_COMPILATION_MODE = wholemodule; 321 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 322 | }; 323 | name = Release; 324 | }; 325 | 5D6C3F782908541200A0C864 /* Debug */ = { 326 | isa = XCBuildConfiguration; 327 | buildSettings = { 328 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 329 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 330 | CODE_SIGN_ENTITLEMENTS = ViewLifecycle/Entitlements.entitlements; 331 | CODE_SIGN_STYLE = Automatic; 332 | CURRENT_PROJECT_VERSION = 1; 333 | DEAD_CODE_STRIPPING = YES; 334 | DEVELOPMENT_ASSET_PATHS = "\"ViewLifecycle/Preview Content\""; 335 | DEVELOPMENT_TEAM = ""; 336 | ENABLE_HARDENED_RUNTIME = YES; 337 | ENABLE_PREVIEWS = YES; 338 | GENERATE_INFOPLIST_FILE = YES; 339 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 340 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 341 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 342 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 343 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 344 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 345 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 346 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 347 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 348 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 349 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 350 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 351 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 352 | MACOSX_DEPLOYMENT_TARGET = 13.0; 353 | MARKETING_VERSION = 1.0; 354 | PRODUCT_BUNDLE_IDENTIFIER = net.oleb.ViewLifecycle; 355 | PRODUCT_NAME = "$(TARGET_NAME)"; 356 | SDKROOT = auto; 357 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 358 | SWIFT_EMIT_LOC_STRINGS = YES; 359 | SWIFT_VERSION = 5.0; 360 | TARGETED_DEVICE_FAMILY = "1,2"; 361 | }; 362 | name = Debug; 363 | }; 364 | 5D6C3F792908541200A0C864 /* Release */ = { 365 | isa = XCBuildConfiguration; 366 | buildSettings = { 367 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 368 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 369 | CODE_SIGN_ENTITLEMENTS = ViewLifecycle/Entitlements.entitlements; 370 | CODE_SIGN_STYLE = Automatic; 371 | CURRENT_PROJECT_VERSION = 1; 372 | DEAD_CODE_STRIPPING = YES; 373 | DEVELOPMENT_ASSET_PATHS = "\"ViewLifecycle/Preview Content\""; 374 | DEVELOPMENT_TEAM = ""; 375 | ENABLE_HARDENED_RUNTIME = YES; 376 | ENABLE_PREVIEWS = YES; 377 | GENERATE_INFOPLIST_FILE = YES; 378 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 379 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 380 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 381 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 382 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 383 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 384 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 385 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 386 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 387 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 388 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 389 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 390 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 391 | MACOSX_DEPLOYMENT_TARGET = 13.0; 392 | MARKETING_VERSION = 1.0; 393 | PRODUCT_BUNDLE_IDENTIFIER = net.oleb.ViewLifecycle; 394 | PRODUCT_NAME = "$(TARGET_NAME)"; 395 | SDKROOT = auto; 396 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 397 | SWIFT_EMIT_LOC_STRINGS = YES; 398 | SWIFT_VERSION = 5.0; 399 | TARGETED_DEVICE_FAMILY = "1,2"; 400 | }; 401 | name = Release; 402 | }; 403 | /* End XCBuildConfiguration section */ 404 | 405 | /* Begin XCConfigurationList section */ 406 | 5D6C3F632908541100A0C864 /* Build configuration list for PBXProject "ViewLifecycle" */ = { 407 | isa = XCConfigurationList; 408 | buildConfigurations = ( 409 | 5D6C3F752908541200A0C864 /* Debug */, 410 | 5D6C3F762908541200A0C864 /* Release */, 411 | ); 412 | defaultConfigurationIsVisible = 0; 413 | defaultConfigurationName = Release; 414 | }; 415 | 5D6C3F772908541200A0C864 /* Build configuration list for PBXNativeTarget "ViewLifecycle" */ = { 416 | isa = XCConfigurationList; 417 | buildConfigurations = ( 418 | 5D6C3F782908541200A0C864 /* Debug */, 419 | 5D6C3F792908541200A0C864 /* Release */, 420 | ); 421 | defaultConfigurationIsVisible = 0; 422 | defaultConfigurationName = Release; 423 | }; 424 | /* End XCConfigurationList section */ 425 | }; 426 | rootObject = 5D6C3F602908541100A0C864 /* Project object */; 427 | } 428 | -------------------------------------------------------------------------------- /ViewLifecycle.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ViewLifecycle.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ViewLifecycle/App.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ViewLifecycleApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | RootView() 8 | } 9 | #if os(macOS) 10 | Window("NavigationStack", id: "navigation-stack") { 11 | CaseStudyNavigationStack() 12 | .toolbar { 13 | ToolbarItem { 14 | Color.clear 15 | } 16 | } 17 | } 18 | #endif 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ViewLifecycle/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ViewLifecycle/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ViewLifecycle/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudies.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Category: Identifiable { 4 | var id: String 5 | var label: LocalizedStringKey 6 | var elements: [CaseStudy] 7 | } 8 | 9 | struct CaseStudy: Identifiable, Equatable { 10 | var id: ID 11 | var label: LocalizedStringKey 12 | var description: LocalizedStringKey? 13 | 14 | enum ID: CaseIterable { 15 | case id 16 | case ifElse 17 | case lazyVGrid 18 | case lazyVStack 19 | case listDynamic 20 | case listStatic 21 | case navigationStack 22 | case opacity 23 | case scrollViewDynamic 24 | case scrollViewStatic 25 | case `switch` 26 | case tabView 27 | } 28 | } 29 | 30 | let categories: [Category] = [ 31 | Category( 32 | id: "simple", 33 | label: "Simple views", 34 | elements: [ 35 | CaseStudy(id: .ifElse, label: "if/else"), 36 | CaseStudy(id: .switch, label: "switch"), 37 | CaseStudy(id: .id, label: ".id(_:)"), 38 | CaseStudy(id: .opacity, label: ".opacity(_:)") 39 | ] 40 | ), 41 | Category( 42 | id: "scrollview", 43 | label: "ScrollView", 44 | elements: [ 45 | CaseStudy(id: .scrollViewStatic, label: "ScrollView with static content"), 46 | CaseStudy( 47 | id: .scrollViewDynamic, 48 | label: "ScrollView with dynamic content", 49 | description: "A VStack with dynamic content, embedded in a ScrollView." 50 | ), 51 | ] 52 | ), 53 | Category( 54 | id: "list", 55 | label: "List", 56 | elements: [ 57 | CaseStudy(id: .listDynamic, label: "List with dynamic content"), 58 | CaseStudy( 59 | id: .listStatic, 60 | label: "List with static content", 61 | description: "A List with a bunch of hardcoded child views, not using ForEach." 62 | ), 63 | ] 64 | ), 65 | Category( 66 | id: "lazy", 67 | label: "Lazy containers", 68 | elements: [ 69 | CaseStudy(id: .lazyVStack, label: "LazyVStack"), 70 | CaseStudy(id: .lazyVGrid, label: "LazyVGrid"), 71 | ] 72 | ), 73 | Category( 74 | id: "navigation", 75 | label: "Navigation containers", 76 | elements: [ 77 | CaseStudy( 78 | id: .navigationStack, 79 | label: "NavigationStack", 80 | description: "A NavigationStack with infinite levels of drill-down." 81 | ), 82 | CaseStudy( 83 | id: .tabView, 84 | label: "TabView", 85 | description: "TabView with multiple tabs, each with static content." 86 | ), 87 | ] 88 | ), 89 | ] 90 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyIDModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyIDModifier: View { 4 | @State private var generation: Int = 0 5 | 6 | var body: some View { 7 | VStack { 8 | Button("Increment view ID") { 9 | generation &+= 1 10 | } 11 | .buttonStyle(.bordered) 12 | 13 | LifecycleMonitor(label: ".id(\(generation))") 14 | .id(generation) 15 | 16 | Text("`.id(_:)` resets the view identity (and hence the view’s state) whenever the argument changes. It’s as if an entirely new view is created.") 17 | .font(.caption) 18 | .frame(maxWidth: .infinity, alignment: .leading) 19 | } 20 | .padding() 21 | .navigationTitle(".id(_:)") 22 | } 23 | } 24 | 25 | struct CaseStudyIDModifier_Previews: PreviewProvider { 26 | static var previews: some View { 27 | CaseStudyIDModifier() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyIfElse.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyIfElse: View { 4 | @State private var flag: Bool = true 5 | 6 | var body: some View { 7 | VStack { 8 | Toggle(isOn: $flag) { 9 | Text("if/else toggle") 10 | } 11 | if flag { 12 | LifecycleMonitor(label: "on") 13 | } else { 14 | LifecycleMonitor(label: "off") 15 | } 16 | Text("Toggling the switch toggles between the true and false branches of an `if`/`else` statement. Observe that the view is destroyed on every toggle.") 17 | .font(.callout) 18 | .frame(maxWidth: .infinity, alignment: .leading) 19 | } 20 | .padding() 21 | .navigationTitle("if/else") 22 | } 23 | } 24 | 25 | struct CaseStudyIfElse_Previews: PreviewProvider { 26 | static var previews: some View { 27 | CaseStudyIfElse() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyLazyVGrid.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyLazyVGrid: View { 4 | private static let initialItemCount = 40 5 | @State private var items: [Item] = (1...Self.initialItemCount).map { i in 6 | Item(id: "Item \(i)") 7 | } 8 | @State private var nextID: Int = Self.initialItemCount + 1 9 | 10 | var body: some View { 11 | ScrollView { 12 | LazyVGrid(columns: [.init(.adaptive(minimum: 180), spacing: 0)]) { 13 | ForEach(items) { item in 14 | VStack(spacing: 4) { 15 | LifecycleMonitor(label: item.id) 16 | Button(role: .destructive) { 17 | if let idx = items.firstIndex(where: { $0.id == item.id }) { 18 | items.remove(at: idx) 19 | } 20 | } label: { 21 | Label("Delete", systemImage: "minus.circle") 22 | } 23 | } 24 | .padding(4) 25 | } 26 | } 27 | } 28 | .safeAreaInset(edge: .bottom) { 29 | Text("`LazyVGrid` behaves almost like `List`: `onAppear` gets called often, but the state gets preserved for all child views. Unlike `List`, `onDisappear` seems not to get called at all.") 30 | .font(.callout) 31 | .padding() 32 | .frame(maxWidth: .infinity, alignment: .leading) 33 | .background(.regularMaterial) 34 | } 35 | .toolbar { 36 | ToolbarItem { 37 | Button("Prepend") { 38 | let newItem = Item(id: "Item \(nextID)") 39 | nextID += 1 40 | items.insert(newItem, at: 0) 41 | } 42 | } 43 | ToolbarItem { 44 | Button("Append") { 45 | let newItem = Item(id: "Item \(nextID)") 46 | nextID += 1 47 | items.append(newItem) 48 | } 49 | } 50 | } 51 | .animation(.default, value: items) 52 | .navigationTitle("LazyVGrid") 53 | } 54 | } 55 | 56 | struct CaseStudyLazyVGrid_Previews: PreviewProvider { 57 | static var previews: some View { 58 | CaseStudyLazyVGrid() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyLazyVStack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyLazyVStack: View { 4 | private static let initialItemCount = 40 5 | @State private var items: [Item] = (1...Self.initialItemCount).map { i in 6 | Item(id: "Item \(i)") 7 | } 8 | @State private var nextID: Int = Self.initialItemCount + 1 9 | 10 | var body: some View { 11 | ScrollView { 12 | LazyVStack { 13 | ForEach(items) { item in 14 | VStack(spacing: 4) { 15 | LifecycleMonitor(label: item.id) 16 | Button(role: .destructive) { 17 | if let idx = items.firstIndex(where: { $0.id == item.id }) { 18 | items.remove(at: idx) 19 | } 20 | } label: { 21 | Label("Delete", systemImage: "minus.circle") 22 | } 23 | } 24 | .padding(4) 25 | } 26 | } 27 | } 28 | .safeAreaInset(edge: .bottom) { 29 | Text("`LazyVStack` behaves almost like `List`: `onAppear` gets called often, but the state gets preserved for all child views. Unlike `List`, `onDisappear` seems not to get called at all.") 30 | .font(.callout) 31 | .padding() 32 | .frame(maxWidth: .infinity, alignment: .leading) 33 | .background(.regularMaterial) 34 | } 35 | .toolbar { 36 | ToolbarItem { 37 | Button("Prepend") { 38 | let newItem = Item(id: "Item \(nextID)") 39 | nextID += 1 40 | items.insert(newItem, at: 0) 41 | } 42 | } 43 | ToolbarItem { 44 | Button("Append") { 45 | let newItem = Item(id: "Item \(nextID)") 46 | nextID += 1 47 | items.append(newItem) 48 | } 49 | } 50 | } 51 | .animation(.default, value: items) 52 | .navigationTitle("LazyVStack") 53 | } 54 | } 55 | 56 | struct CaseStudyLazyVStack_Previews: PreviewProvider { 57 | static var previews: some View { 58 | CaseStudyLazyVStack() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyListDynamic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyListDynamic: View { 4 | private static let initialItemCount = 40 5 | @State private var items: [Item] = (1...Self.initialItemCount).map { i in 6 | Item(id: "Item \(i)") 7 | } 8 | @State private var nextID: Int = Self.initialItemCount + 1 9 | 10 | var body: some View { 11 | List { 12 | ForEach(items) { item in 13 | LifecycleMonitor(label: item.id) 14 | } 15 | .onDelete { offsets in 16 | items.remove(atOffsets: offsets) 17 | } 18 | } 19 | .safeAreaInset(edge: .bottom) { 20 | Text("`List` recycles views during scrolling, so `onAppear` gets called often. But `List` preserves the state for all list items.") 21 | .font(.callout) 22 | .padding() 23 | .frame(maxWidth: .infinity, alignment: .leading) 24 | .background(.regularMaterial) 25 | } 26 | .toolbar { 27 | ToolbarItem { 28 | Button("Prepend") { 29 | let newItem = Item(id: "Item \(nextID)") 30 | nextID += 1 31 | items.insert(newItem, at: 0) 32 | } 33 | } 34 | ToolbarItem { 35 | Button("Append") { 36 | let newItem = Item(id: "Item \(nextID)") 37 | nextID += 1 38 | items.append(newItem) 39 | } 40 | } 41 | } 42 | .animation(.default, value: items) 43 | .navigationTitle("Dynamic List") 44 | } 45 | } 46 | 47 | struct CaseStudyList_Previews: PreviewProvider { 48 | static var previews: some View { 49 | NavigationStack { 50 | CaseStudyListDynamic() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyListStatic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyListStatic: View { 4 | var body: some View { 5 | List { 6 | Group { 7 | LifecycleMonitor(label: "Child 0") 8 | LifecycleMonitor(label: "Child 1") 9 | LifecycleMonitor(label: "Child 2") 10 | LifecycleMonitor(label: "Child 3") 11 | LifecycleMonitor(label: "Child 4") 12 | LifecycleMonitor(label: "Child 5") 13 | LifecycleMonitor(label: "Child 6") 14 | LifecycleMonitor(label: "Child 7") 15 | LifecycleMonitor(label: "Child 8") 16 | LifecycleMonitor(label: "Child 9") 17 | } 18 | Group { 19 | LifecycleMonitor(label: "Child 10") 20 | LifecycleMonitor(label: "Child 11") 21 | LifecycleMonitor(label: "Child 12") 22 | LifecycleMonitor(label: "Child 13") 23 | LifecycleMonitor(label: "Child 14") 24 | LifecycleMonitor(label: "Child 15") 25 | LifecycleMonitor(label: "Child 16") 26 | LifecycleMonitor(label: "Child 17") 27 | LifecycleMonitor(label: "Child 18") 28 | LifecycleMonitor(label: "Child 19") 29 | } 30 | Group { 31 | LifecycleMonitor(label: "Child 20") 32 | LifecycleMonitor(label: "Child 21") 33 | LifecycleMonitor(label: "Child 22") 34 | LifecycleMonitor(label: "Child 23") 35 | LifecycleMonitor(label: "Child 24") 36 | LifecycleMonitor(label: "Child 25") 37 | LifecycleMonitor(label: "Child 26") 38 | LifecycleMonitor(label: "Child 27") 39 | LifecycleMonitor(label: "Child 28") 40 | LifecycleMonitor(label: "Child 29") 41 | } 42 | } 43 | .safeAreaInset(edge: .bottom) { 44 | Text("Lists with static content recycle their child views too. They behave like dynamic lists.") 45 | .font(.callout) 46 | .padding() 47 | .frame(maxWidth: .infinity, alignment: .leading) 48 | .background(.regularMaterial) 49 | } 50 | .navigationTitle("Static List") 51 | } 52 | } 53 | 54 | struct CaseStudyStaticList_Previews: PreviewProvider { 55 | static var previews: some View { 56 | CaseStudyListStatic() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyNavigationStack.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(macOS) 4 | struct CaseStudyNavigationStackMac: View { 5 | @Environment(\.openWindow) private var openWindow: OpenWindowAction 6 | 7 | var body: some View { 8 | VStack { 9 | Text("This example opens in a separate window because SwiftUI on macOS 13.1 can’t seem to handle a NavigationStack nested in a NavigationSplitView (or I’m holding it wrong; I’m running into an infinite loop with this setup).") 10 | Button("Open NavigationStack window") { 11 | openWindow(id: "navigation-stack") 12 | } 13 | } 14 | .padding() 15 | .navigationTitle("NavigationStack") 16 | } 17 | } 18 | #endif 19 | 20 | struct CaseStudyNavigationStack: View { 21 | @State private var navigationPath: [Int] = [] 22 | 23 | var body: some View { 24 | NavigationStack(path: $navigationPath) { 25 | Content(level: 1) 26 | .navigationDestination(for: Int.self) { level in 27 | Content(level: level) 28 | } 29 | } 30 | } 31 | 32 | struct Content: View { 33 | var level: Int 34 | 35 | var body: some View { 36 | List { 37 | Section { 38 | NavigationLink(value: level + 1) { 39 | LifecycleMonitor(label: "Level \(level)") 40 | } 41 | } footer: { 42 | if level == 1 { 43 | Text("Navigation views keep the state of content views on the navigation stack alive. `onAppear` and `onDisappear` get called as you navigate. Popping a view off the stack ends the view's lifetime, destroying its state.") 44 | .font(.callout) 45 | .frame(maxWidth: .infinity, alignment: .leading) 46 | } 47 | } 48 | } 49 | .listStyle(.plain) 50 | .navigationTitle(level == 1 ? "NavigationStack" : "Level \(level)") 51 | } 52 | } 53 | } 54 | 55 | struct CaseStudyNavigationStack_Previews: PreviewProvider { 56 | static var previews: some View { 57 | CaseStudyNavigationStack() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyOpacity.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyOpacity: View { 4 | @State private var opacity: Double = 1.0 5 | 6 | var body: some View { 7 | VStack { 8 | LabeledContent { 9 | Slider(value: $opacity, in: 0...1) 10 | } label: { 11 | Text("Opacity: \(opacity, format: .percent.precision(.fractionLength(0)))") 12 | } 13 | LifecycleMonitor(label: ".opacity(_:)") 14 | .opacity(opacity) 15 | Text("The `.opacity` modifier has no effect on a view’s lifecycle. Setting the opacity to 0 does *not* call `onDisappear`.") 16 | .font(.callout) 17 | .frame(maxWidth: .infinity, alignment: .leading) 18 | } 19 | .padding() 20 | .navigationTitle("if/else") 21 | } 22 | } 23 | 24 | struct CaseStudyOpacity_Previews: PreviewProvider { 25 | static var previews: some View { 26 | CaseStudyOpacity() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyScrollViewDynamic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyScrollViewDynamic: View { 4 | private static let initialItemCount = 40 5 | @State private var items: [Item] = (1...Self.initialItemCount).map { i in 6 | Item(id: "Item \(i)") 7 | } 8 | @State private var nextID: Int = Self.initialItemCount + 1 9 | 10 | var body: some View { 11 | ScrollView { 12 | VStack { 13 | ForEach(items) { item in 14 | HStack { 15 | LifecycleMonitor(label: item.id) 16 | Button(role: .destructive) { 17 | if let idx = items.firstIndex(where: { $0.id == item.id }) { 18 | items.remove(at: idx) 19 | } 20 | } label: { 21 | Label("Delete", systemImage: "minus.circle") 22 | .labelStyle(.iconOnly) 23 | } 24 | } 25 | .padding() 26 | } 27 | } 28 | } 29 | .safeAreaInset(edge: .bottom) { 30 | Text("Unlike `List`, a `ScrollView` has no effect on its content views’ lifecycle, even if those content views are created dynamically with `ForEach`. All children appear at once and never disappear, even if they’re not on screen initially.") 31 | .font(.callout) 32 | .padding() 33 | .frame(maxWidth: .infinity, alignment: .leading) 34 | .background(.regularMaterial) 35 | } 36 | .toolbar { 37 | ToolbarItem { 38 | Button("Prepend") { 39 | let newItem = Item(id: "Item \(nextID)") 40 | nextID += 1 41 | items.insert(newItem, at: 0) 42 | } 43 | } 44 | ToolbarItem { 45 | Button("Append") { 46 | let newItem = Item(id: "Item \(nextID)") 47 | nextID += 1 48 | items.append(newItem) 49 | } 50 | } 51 | } 52 | .animation(.default, value: items) 53 | .navigationTitle("Dynamic ScrollView") 54 | } 55 | } 56 | 57 | struct CaseStudyScrollViewVStackForEach_Previews: PreviewProvider { 58 | static var previews: some View { 59 | NavigationStack { 60 | CaseStudyScrollViewDynamic() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyScrollViewStatic.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyScrollViewStatic: View { 4 | var body: some View { 5 | ScrollView { 6 | LifecycleMonitor(label: "ScrollView top") 7 | 8 | Text("Nesting views in a ScrollView has no effect on those views’ lifecycle events. The entire content of the scroll view appears immediately when the scroll view appears, regardless of whether it’s on screen or not.") 9 | .font(.callout) 10 | .frame(maxWidth: .infinity, alignment: .leading) 11 | .padding() 12 | 13 | VStack { 14 | Image(systemName: "arrow.down.circle.fill") 15 | Text("Scroll down") 16 | } 17 | .font(.largeTitle) 18 | .padding() 19 | 20 | Spacer(minLength: 2000) 21 | 22 | LifecycleMonitor(label: "ScrollView bottom") 23 | } 24 | .navigationTitle("Static ScrollView") 25 | } 26 | } 27 | 28 | struct CaseStudyScrollView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | CaseStudyScrollViewStatic() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudySwitch.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum Cases { 4 | case one 5 | case two 6 | case three 7 | } 8 | 9 | struct CaseStudySwitch: View { 10 | @State private var state: Cases = .one 11 | 12 | var body: some View { 13 | VStack { 14 | Picker("Selection", selection: $state) { 15 | Text("One").tag(Cases.one) 16 | Text("Two").tag(Cases.two) 17 | Text("Three").tag(Cases.three) 18 | } 19 | .pickerStyle(.segmented) 20 | 21 | switch state { 22 | case .one: 23 | LifecycleMonitor(label: "One") 24 | case .two: 25 | LifecycleMonitor(label: "Two") 26 | case .three: 27 | LifecycleMonitor(label: "Three") 28 | } 29 | 30 | Text("A `switch` statement behaves like a series of `if`/`else` branches. Content views are fully destroyed and recreated as you switch between states.") 31 | .font(.callout) 32 | .frame(maxWidth: .infinity, alignment: .leading) 33 | } 34 | .padding() 35 | .navigationTitle("switch") 36 | } 37 | } 38 | 39 | struct CaseStudySwitch_Previews: PreviewProvider { 40 | static var previews: some View { 41 | CaseStudySwitch() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ViewLifecycle/CaseStudyTabView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaseStudyTabView: View { 4 | var body: some View { 5 | TabView { 6 | VStack { 7 | LifecycleMonitor(label: "Tab 1") 8 | Text("`TabView` initializes the state for each tab’s content view all at once when it first appears. `onAppear` and `onDisappear` get called as you switch between tabs. State of offscreen tabs is kept alive.") 9 | .font(.callout) 10 | .frame(maxWidth: .infinity, alignment: .leading) 11 | } 12 | .padding() 13 | .tabItem { 14 | Label("Tab 1", systemImage: "1.circle") 15 | } 16 | LifecycleMonitor(label: "Tab 2") 17 | .padding() 18 | .tabItem { 19 | Label("Tab 2", systemImage: "2.circle") 20 | } 21 | LifecycleMonitor(label: "Tab 3") 22 | .padding() 23 | .tabItem { 24 | Label("Tab 3", systemImage: "3.circle") 25 | } 26 | LifecycleMonitor(label: "Tab 4") 27 | .padding() 28 | .tabItem { 29 | Label("Tab 4", systemImage: "4.circle") 30 | } 31 | } 32 | .navigationTitle("TabView") 33 | } 34 | } 35 | 36 | struct CaseStudyTabView_Previews: PreviewProvider { 37 | static var previews: some View { 38 | CaseStudyTabView() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ViewLifecycle/Entitlements.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ViewLifecycle/LifecycleMonitor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that records and displays its lifecycle events. 4 | struct LifecycleMonitor: View { 5 | var label: String 6 | @State private var stateTimestamp: Date = .now 7 | @State private var onAppearTimestamp: Date? = nil 8 | @State private var onDisappearTimestamp: Date? = nil 9 | @State private var color: Color = .random() 10 | 11 | var body: some View { 12 | VStack(spacing: 16) { 13 | Text(label) 14 | .font(.title3) 15 | Grid(horizontalSpacing: 16) { 16 | GridRow(alignment: .firstTextBaseline) { 17 | Text("@State") 18 | .gridColumnAlignment(.leading) 19 | Text("\(stateTimestamp, style: .timer) ago") 20 | .monospacedDigit() 21 | .gridColumnAlignment(.leading) 22 | } 23 | .help("When the state (incl. @State and @StateObject) for this view was created") 24 | 25 | GridRow(alignment: .firstTextBaseline) { 26 | Text("onAppear") 27 | Text(timestampLabel(for: onAppearTimestamp)) 28 | .monospacedDigit() 29 | } 30 | .help("When onAppear was last called for this view") 31 | 32 | GridRow(alignment: .firstTextBaseline) { 33 | Text("onDisappear") 34 | Text(timestampLabel(for: onDisappearTimestamp)) 35 | .monospacedDigit() 36 | } 37 | .help("When onDisappear was last called for this view") 38 | } 39 | .font(.callout) 40 | } 41 | .padding() 42 | .frame(maxWidth: .infinity) 43 | .background { 44 | RoundedRectangle(cornerRadius: 16) 45 | .fill(color) 46 | } 47 | .onAppear { 48 | let timestamp = Date.now 49 | print("\(timestamp) \(label): onAppear") 50 | let animation: Animation? = onAppearTimestamp == nil ? nil : .easeOut(duration: 1) 51 | withAnimation(animation) { 52 | onAppearTimestamp = timestamp 53 | } 54 | } 55 | .onDisappear { 56 | let timestamp = Date.now 57 | print("\(timestamp) \(label): onDisappear") 58 | let animation: Animation? = onDisappearTimestamp == nil ? nil : .easeOut(duration: 1) 59 | withAnimation(animation) { 60 | onDisappearTimestamp = timestamp 61 | } 62 | } 63 | } 64 | 65 | private func timestampLabel(for timestamp: Date?) -> LocalizedStringKey { 66 | if let t = timestamp { 67 | return "\(t, style: .timer) ago" 68 | } else { 69 | return "never" 70 | } 71 | } 72 | } 73 | 74 | struct LifecycleMonitor_Previews: PreviewProvider { 75 | static var previews: some View { 76 | List { 77 | ForEach(1..<100) { i in 78 | LifecycleMonitor(label: "\(i)") 79 | } 80 | } 81 | } 82 | } 83 | 84 | extension Color { 85 | static func random() -> Self { 86 | Color( 87 | red: .random(in: 0.5...0.9), 88 | green: .random(in: 0.5...0.9), 89 | blue: .random(in: 0.5...0.9) 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ViewLifecycle/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ViewLifecycle/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RootView: View { 4 | @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn 5 | 6 | var body: some View { 7 | NavigationSplitView(columnVisibility: $columnVisibility) { 8 | Sidebar() 9 | } detail: { 10 | Text("Select an item in the sidebar.") 11 | } 12 | } 13 | } 14 | 15 | struct Sidebar: View { 16 | @State private var selection: CaseStudy? = nil 17 | 18 | var body: some View { 19 | List { 20 | ForEach(categories) { section in 21 | Section { 22 | ForEach(section.elements) { caseStudy in 23 | NavigationLink(value: caseStudy.id) { 24 | VStack(alignment: .leading, spacing: 4) { 25 | Text(caseStudy.label) 26 | .lineLimit(nil) 27 | if let description = caseStudy.description { 28 | Text(description) 29 | .font(.callout) 30 | .foregroundStyle(.secondary) 31 | .lineLimit(nil) 32 | } 33 | } 34 | } 35 | } 36 | } header: { 37 | Text(section.label) 38 | } 39 | } 40 | } 41 | .navigationTitle("SwiftUI View Lifecycle") 42 | #if !os(macOS) 43 | .navigationBarTitleDisplayMode(.inline) 44 | #endif 45 | .navigationDestination(for: CaseStudy.ID.self) { id in 46 | MainContent(caseStudyID: id) 47 | } 48 | } 49 | } 50 | 51 | struct MainContent: View { 52 | var caseStudyID: CaseStudy.ID 53 | 54 | var body: some View { 55 | switch caseStudyID { 56 | case .ifElse: 57 | CaseStudyIfElse() 58 | case .switch: 59 | CaseStudySwitch() 60 | case .id: 61 | CaseStudyIDModifier() 62 | case .opacity: 63 | CaseStudyOpacity() 64 | case .scrollViewStatic: 65 | CaseStudyScrollViewStatic() 66 | case .scrollViewDynamic: 67 | CaseStudyScrollViewDynamic() 68 | case .listDynamic: 69 | CaseStudyListDynamic() 70 | case .listStatic: 71 | CaseStudyListStatic() 72 | case .lazyVStack: 73 | CaseStudyLazyVStack() 74 | case .lazyVGrid: 75 | CaseStudyLazyVGrid() 76 | case .navigationStack: 77 | #if os(macOS) 78 | CaseStudyNavigationStackMac() 79 | #else 80 | CaseStudyNavigationStack() 81 | #endif 82 | case .tabView: 83 | CaseStudyTabView() 84 | } 85 | } 86 | } 87 | 88 | struct RootView_Previews: PreviewProvider { 89 | static var previews: some View { 90 | RootView() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /ViewLifecycle/SampleModel.swift: -------------------------------------------------------------------------------- 1 | struct Item: Hashable, Identifiable { 2 | var id: String 3 | } 4 | -------------------------------------------------------------------------------- /assets/LifecycleMonitor-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ole/swiftui-view-lifecycle/14f55a35db7e3845cd99b159c2d3ee41bcac023c/assets/LifecycleMonitor-example.png -------------------------------------------------------------------------------- /assets/ios-collage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ole/swiftui-view-lifecycle/14f55a35db7e3845cd99b159c2d3ee41bcac023c/assets/ios-collage.png -------------------------------------------------------------------------------- /assets/ipad-tabview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ole/swiftui-view-lifecycle/14f55a35db7e3845cd99b159c2d3ee41bcac023c/assets/ipad-tabview.png -------------------------------------------------------------------------------- /assets/mac-list-dynamic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ole/swiftui-view-lifecycle/14f55a35db7e3845cd99b159c2d3ee41bcac023c/assets/mac-list-dynamic.png --------------------------------------------------------------------------------