Tools: Animated iOS Splash Screens: The Illusion Apple Actually Allows

Tools: Animated iOS Splash Screens: The Illusion Apple Actually Allows

Source: Dev.to

First, let's clear up a very common misunderstanding ## So how do apps like Uber pull off animated splash screens? ## Setting up the static Launch Screen (the right way) ## Step 1: Open LaunchScreen.storyboard ## Step 2: Add an Image View ## Step 3: Verify the hierarchy ## Step 4: Add your logo to Assets ## Step 5: Assign the image ## Step 6: Set the background color ## What's next? ## The Real Dynamic Splash Screen (a.k.a. The Illusion That Everyone Uses) ## The "Bait and Switch" Technique ## 1. The Bait — Static Launch Screen ## 2. The Switch — Dynamic Overlay ## 3. The Animation ## 4. The Transition ## Step-by-Step: Building the Dynamic Splash Screen ## 1. Creating the SplashViewController ## 2. Starting the Animation ## Wiring It Up in SceneDelegate ## Swapping to the Real App ## What You've Actually Built ## Optional follow-ups you could add later You've seen it a hundred times. You tap an app icon. A logo fades in. Something animates. For a brief moment, you think, "Nice. This app feels polished." Then you try to build the same thing in iOS… and the splash screen refuses to animate. Or worse, Apple refuses your App Store submission. Welcome to the confusing world of iOS splash screens—where what users see, what developers call it, and what Apple actually allows are three completely different things. Apps like Uber, Swiggy, and Indigo appear to pull off beautifully animated splash screens. But here's the uncomfortable truth: those animations are not happening on the launch screen at all. And once you understand why, a lot of Apple's "unreasonable" restrictions suddenly start to make sense. This article breaks down what a splash screen really is on iOS, why it's intentionally boring, and how to build a dynamic splash experience without fighting the system—or App Review. On iOS, what developers casually call a "splash screen" is officially the Launch Screen. And here's the catch: The launch screen is not a view controller. It's a static launch asset, loaded before your app process is fully alive. Because of that, Apple places some strict limitations on it: If you've ever wondered why your animated launch screen idea didn't work—this is why. The system simply isn't ready to run your code yet. They don't animate the launch screen. They animate the first screen of the app. We'll get to that later. First, let's set up the static part properly. Note: We will be dealing with the integration in an UIKit project. We'll start by creating a clean, minimal launch screen using LaunchScreen.storyboard. No hacks. No Info.plist gymnastics. Just UIKit doing UIKit things. Once you open your project in Xcode, you'll find a file named LaunchScreen.storyboard in the project navigator on the left. Open it, and you'll see a very boring, very empty layout. This is expected. Apple wants your launch screen to be predictable, not exciting. If you're using Xcode 16+, open the Add Library panel and search for Image View. Drop the UIImageView onto the canvas and position it wherever your design demands—usually centered, unless you're feeling rebellious. Expand the View Controller Scene, then expand the View. You should see your image view neatly sitting there, behaving itself. This step isn't strictly required, but it's a good habit—especially when things don't show up and Xcode gaslights you into thinking they do. Now add your app logo to the asset catalog. A few recommendations: Your future self will thank you. Select the image view, open the Attributes Inspector, and set the Image property to the asset name you just added. At this point, you should finally see something on screen. Small win. Celebrate quietly. Select the root View of the view controller. In the Attributes Inspector, set the Background color to match your brand. That's it. You now have a proper, Apple-approved static launch screen. While setting up the color, set it for both dark mode and light mode. No code. No animation. No drama. This launch screen exists only to bridge the gap between tapping the app icon and your app becoming ready to render UI. That all belongs in the first real screen of your app, not here. In the previous section, we established an uncomfortable truth: iOS launch screens are intentionally boring. So how do apps still manage to show animated splash screens? Simple. They cheat. Respectfully. If you want a dynamic splash screen on iOS, you don't animate the launch screen. You replace it immediately with something that looks identical—but is actually a real view controller where Swift is finally allowed to exist. This works because users don't care what is animating. They only care that something is. This pattern is so common that if your app doesn't do it, you're probably overthinking things. The system shows LaunchScreen.storyboard. This happens instantly, before your app code runs. As soon as your app finishes launching, you present a normal UIViewController that visually matches the launch screen. Because this is now a real view controller, you can run: Apple approves. Swift approves. Life is good. Once the animation finishes (and any startup logic completes), you swap this splash view out for your real app—home screen, login screen, or wherever the user belongs. This view controller is the star of the illusion. Its only job is to look exactly like the launch screen, then gracefully get out of the way. This is where the illusion begins: Nothing fancy here—just ensuring the logo lands exactly where users expect it. We trigger animations in viewDidAppear, not viewDidLoad, because: What's happening here: (Yes, 5 seconds is long—this is just for demonstration. Please don't do this in production.) Now we tell the app: "Show the splash first. Decide everything else later." Standard scene setup. Nothing exciting yet. Now comes the final act: deciding where the user actually goes. We choose the destination dynamically. This mirrors real-world apps: Forces the destination view to load early, ensuring a smooth transition. We take a snapshot of the splash screen and then immediately replace the root controller. This avoids sudden visual jumps. This final fade-out is what makes everything feel deliberate and polished. No flicker. No hard cut. Just a clean exit. Despite appearances, you didn't animate the launch screen at all. And that's exactly how production iOS apps do it. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: import UIKit class SplashViewController: UIViewController { private let logoImageView: UIImageView = { let imageView = UIImageView(image: UIImage(named: "splashscreen")) imageView.contentMode = .scaleAspectFit return imageView }() Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import UIKit class SplashViewController: UIViewController { private let logoImageView: UIImageView = { let imageView = UIImageView(image: UIImage(named: "splashscreen")) imageView.contentMode = .scaleAspectFit return imageView }() CODE_BLOCK: import UIKit class SplashViewController: UIViewController { private let logoImageView: UIImageView = { let imageView = UIImageView(image: UIImage(named: "splashscreen")) imageView.contentMode = .scaleAspectFit return imageView }() CODE_BLOCK: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(named: "SplashBackground") view.addSubview(logoImageView) logoImageView.translatesAutoresizingMaskIntoConstraints = false Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(named: "SplashBackground") view.addSubview(logoImageView) logoImageView.translatesAutoresizingMaskIntoConstraints = false CODE_BLOCK: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(named: "SplashBackground") view.addSubview(logoImageView) logoImageView.translatesAutoresizingMaskIntoConstraints = false CODE_BLOCK: NSLayoutConstraint.activate([ logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), logoImageView.widthAnchor.constraint(equalToConstant: 200), logoImageView.heightAnchor.constraint(equalToConstant: 200) ]) } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: NSLayoutConstraint.activate([ logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), logoImageView.widthAnchor.constraint(equalToConstant: 200), logoImageView.heightAnchor.constraint(equalToConstant: 200) ]) } CODE_BLOCK: NSLayoutConstraint.activate([ logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), logoImageView.widthAnchor.constraint(equalToConstant: 200), logoImageView.heightAnchor.constraint(equalToConstant: 200) ]) } CODE_BLOCK: override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) animate() } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) animate() } CODE_BLOCK: override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) animate() } CODE_BLOCK: private func animate() { UIView.animate(withDuration: 5, animations: { self.logoImageView.transform = CGAffineTransform(scaleX: 3.0, y: 3.0) self.logoImageView.alpha = 0 }) { _ in (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)? .changeRootViewController() } } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: private func animate() { UIView.animate(withDuration: 5, animations: { self.logoImageView.transform = CGAffineTransform(scaleX: 3.0, y: 3.0) self.logoImageView.alpha = 0 }) { _ in (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)? .changeRootViewController() } } } CODE_BLOCK: private func animate() { UIView.animate(withDuration: 5, animations: { self.logoImageView.transform = CGAffineTransform(scaleX: 3.0, y: 3.0) self.logoImageView.alpha = 0 }) { _ in (UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate)? .changeRootViewController() } } } CODE_BLOCK: func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = (scene as? UIWindowScene) else { return } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = (scene as? UIWindowScene) else { return } CODE_BLOCK: func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = (scene as? UIWindowScene) else { return } CODE_BLOCK: let window = UIWindow(windowScene: windowScene) window.backgroundColor = UIColor(named: "SplashBackground") ?? .systemBackground window.rootViewController = SplashViewController() self.window = window window.makeKeyAndVisible() } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: let window = UIWindow(windowScene: windowScene) window.backgroundColor = UIColor(named: "SplashBackground") ?? .systemBackground window.rootViewController = SplashViewController() self.window = window window.makeKeyAndVisible() } CODE_BLOCK: let window = UIWindow(windowScene: windowScene) window.backgroundColor = UIColor(named: "SplashBackground") ?? .systemBackground window.rootViewController = SplashViewController() self.window = window window.makeKeyAndVisible() } CODE_BLOCK: func changeRootViewController() { let destinationVC: UIViewController Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: func changeRootViewController() { let destinationVC: UIViewController CODE_BLOCK: func changeRootViewController() { let destinationVC: UIViewController CODE_BLOCK: var isUserLoggedIn: Bool = false if isUserLoggedIn { destinationVC = MainViewController() } else { destinationVC = LoginViewController() } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: var isUserLoggedIn: Bool = false if isUserLoggedIn { destinationVC = MainViewController() } else { destinationVC = LoginViewController() } CODE_BLOCK: var isUserLoggedIn: Bool = false if isUserLoggedIn { destinationVC = MainViewController() } else { destinationVC = LoginViewController() } CODE_BLOCK: guard let window = self.window else { return } destinationVC.loadViewIfNeeded() Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: guard let window = self.window else { return } destinationVC.loadViewIfNeeded() CODE_BLOCK: guard let window = self.window else { return } destinationVC.loadViewIfNeeded() CODE_BLOCK: let currentView = window.snapshotView(afterScreenUpdates: false) window.rootViewController = destinationVC Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: let currentView = window.snapshotView(afterScreenUpdates: false) window.rootViewController = destinationVC CODE_BLOCK: let currentView = window.snapshotView(afterScreenUpdates: false) window.rootViewController = destinationVC CODE_BLOCK: if let snapshot = currentView { window.addSubview(snapshot) UIView.animate(withDuration: 0.5, animations: { snapshot.alpha = 0 }, completion: { _ in snapshot.removeFromSuperview() }) } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: if let snapshot = currentView { window.addSubview(snapshot) UIView.animate(withDuration: 0.5, animations: { snapshot.alpha = 0 }, completion: { _ in snapshot.removeFromSuperview() }) } } CODE_BLOCK: if let snapshot = currentView { window.addSubview(snapshot) UIView.animate(withDuration: 0.5, animations: { snapshot.alpha = 0 }, completion: { _ in snapshot.removeFromSuperview() }) } } - No Swift or Objective-C code - No API calls - No animations - No conditional logic (dark mode checks, user state, etc.) - Use a simple, flat image - Prefer vector formats - Name it something sensible like splashscreen - Lottie files - API-driven routing - user-specific logic - UIView animations - Lottie animations - Video playback - Async logic - We use the same image asset as the launch screen. - scaleAspectFit ensures the logo behaves nicely across device sizes. - Everything is created programmatically to avoid storyboard dependency. - The background color matches the launch screen. - The logo is added and centered using Auto Layout. - If this matches the launch screen visually, the transition feels seamless. - The view is guaranteed to be on screen - Animations feel smoother - You avoid half-rendered transitions - The logo scales up and fades out - After the animation completes, control is handed back to SceneDelegate - This keeps navigation logic centralized and predictable - The window background matches the launch screen color (prevents flashing) - SplashViewController becomes the first real screen - The illusion remains intact - Logged-in users → Home - Logged-out users → Login - Let iOS do its static launch thing - Immediately replaced it with a lookalike - Ran real animations legally - Transitioned smoothly into the app - Using Lottie instead of UIView.animate - Handling async startup tasks (API, auth, remote config) - SwiftUI version of the same pattern - iOS 17+ Scene lifecycle edge cases