swift – SwiftUI. Subview animation is not working if subview’s View @State was changed while parent View is animating. iOS 16

0
45


I have a structure, where the parent view is a hidden pop-up with content. On some user action parent View starts to slide up. I need all its content to slide up with the parent View. It was working perfectly before iOS 16, but now it’s broken. If the child’s View subview @State is changed during the animation, then this View appears instantly on a screen, i.e. not sliding. As I understand, because View’s @State was changed SwiftUI redraws this particular View and disables its animation, so it appears in its final position without animation. I was trying to force the animation of this particular View using .animation or withAnimation. Nothing of it helped. How to fix this bug in iOS 16?

Minimal reproducible example:

import SwiftUI

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
import SwiftUI

struct ContentView: View {

    @State var shouldShow: SlideCardPosition = .bottom
    @State var text1: String = ""

    var body: some View {
        VStack {
            Button {
                shouldShow = .top
            } label: {
                Text("Show PopUp")
                    .padding(.top, 100)
            }
            PopUpUIView(shouldShow: $shouldShow)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
import SwiftUI

struct SlideOverCard<Content: View>: View {
    @GestureState private var dragState = SlideDragState.inactive
    @Binding private var position: SlideCardPosition
    @State private var highlightedBackground = false
    private var contentHeight: CGFloat
    private var backgroundColor: Color?
    private var withCorners: Bool
    private var isHandleHidden: Bool
    private var overlayOpacity: CGFloat

    init(position: Binding<SlideCardPosition>,
         contentHeight: CGFloat,
         backgroundColor: Color? = nil,
         withCorners: Bool = true,
         isHandleHidden: Bool = false,
         overlayOpacity: CGFloat = 0.75,
         content: @escaping () -> Content) {
        _position = position
        self.content = content
        self.contentHeight = contentHeight
        self.backgroundColor = backgroundColor
        self.withCorners = withCorners
        self.isHandleHidden = isHandleHidden
        self.overlayOpacity = overlayOpacity
    }

    var content: () -> Content
    var body: some View {

        return Rectangle()
            .frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
            .foregroundColor(Color.black.opacity(highlightedBackground ? overlayOpacity : 0))
            .position(x: UIScreen.screenWidth / 2, y: (UIScreen.screenHeight) / 2)
            .edgesIgnoringSafeArea([.top, .bottom])
            .overlay(
                Group {
                    VStack(spacing: 0) {
                        if !isHandleHidden {
                            Handle()
                        }
                        self.content()
                        Spacer()
                    }
                }
                .frame(width: UIScreen.screenWidth, height: UIScreen.screenHeight)
                .background(backgroundColor != nil ? backgroundColor! : Color.black)
                .cornerRadius(withCorners ? 40.0 : 0)
                .shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
                .offset(y: position(from: position) + dragState.translation.height)
                .animation(dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0), value: UUID())
                .edgesIgnoringSafeArea([.top, .bottom])
                .onTapGesture {}
                .onChange(of: position) { _ in
                    withAnimation(.easeInOut) {
                        highlightedBackground.toggle()
                    }
                }
            )
            .onTapGesture {
                position = position == .bottom ? .top : .bottom
            }
    }


    private func position(from cardPosition: SlideCardPosition) -> CGFloat {
        switch cardPosition {
        case .top: return UIScreen.screenHeight - contentHeight - UIScreen.topSafeAreaHeight
        case .bottom: return 1000
        }
    }
}

enum SlideCardPosition {
    case top
    case bottom
}

private enum SlideDragState {
    case inactive
    case dragging(translation: CGSize)

    var translation: CGSize {
        switch self {
        case .inactive:
            return .zero
        case .dragging(let translation):
            return translation
        }
    }

    var isDragging: Bool {
        switch self {
        case .inactive:
            return false
        case .dragging:
            return true
        }
    }
}

private struct Handle: View {
    private let handleThickness: CGFloat = 5
    var body: some View {
        RoundedRectangle(cornerRadius: handleThickness / 2.0)
            .frame(width: 34, height: handleThickness)
            .foregroundColor(.white)
            .padding(.top, 8)
    }
}
import UIKit

extension UIScreen {
    static let screenWidth = UIScreen.main.bounds.size.width
    static let screenHeight = UIScreen.main.bounds.size.height
    static let screenSize = UIScreen.main.bounds.size
    private static let window = UIApplication.shared.windows[0]
    private static let safeFrame = window.safeAreaLayoutGuide.layoutFrame

    static var topSafeAreaHeight: CGFloat {
        safeFrame.minY
    }

    static var bottomSafeAreaHeight: CGFloat {
        window.frame.maxY - safeFrame.maxY
    }
}
import SwiftUI

struct PopUpUIView: View {

    @Binding var shouldShow: SlideCardPosition
    @State var text1 = "some random text"

    var body: some View {
        SlideOverCard(position: $shouldShow,
                      contentHeight: 300) {
            VStack(spacing: 10) {
                Text(text1)
                    .foregroundColor(.white)
                    .padding(.top, 80)
            }
        }.onChange(of: shouldShow) { _ in
            if shouldShow == .top {
                text1 = UUID().uuidString
            }
        }
    }
}

struct PopUpUIView_Previews: PreviewProvider {
    static var previews: some View {
        PopUpUIView(shouldShow: .constant(.bottom))
    }
}

Example of incorrect animation with a dynamic text.

enter image description here

Example of what I want to achieve. It is working fine if text is static.

enter image description here