首页 > 解决方案 > 将 @State 属性切换为 @Binding 属性会干扰动画

问题描述

我目前正在构建一个像 Apple Map 一样的底部 UI,我遇到了一些我正在努力修复的奇怪错误。

以下代码段代表底部工作表,并包含一个随时可用的预览。您可以尝试一下,看看模拟器和 XCode 的预览引擎中的一切都按预期工作。

import SwiftUI
import MapKit

struct ReleaseGesture<Header: View, Content: View>: View {
    
    // MARK: Init properties
        
    // Height of the provided header view
    let headerHeight: CGFloat
    
    // Height of the provided content view
    let contentHeight: CGFloat
    
    // The spacing between the header and the content
    let separation: CGFloat
    
    let header: () -> Header
    let content: () -> Content
    
    
    // MARK: State
    @State private var opened = false
    @GestureState private var translation: CGFloat = 0
    
    
    // MARK: Constants
    let capsuleHeight: CGFloat = 5
    let capsulePadding: CGFloat = 5
    
    
    // MARK: Computed properties
    
    // The current static value that is always taken into account to compute the sheet's position
    private var offset: CGFloat {
        self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
    }
    
    // Gesture used for the snap animation
    private var gesture: some Gesture {
        DragGesture()
            .updating(self.$translation) { value, state, transaction in
                state = -value.translation.height
            }
            .onEnded {_ in
                self.opened.toggle()
            }
    }
    
    // Animation used when the drag stops
    private var animation: Animation {
        .spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
    }


    // Drag indicator used to indicate the user can drag the sheet
    private var dragIndicator: some View {
        Capsule()
            .fill(Color.gray.opacity(0.4))
            .frame(width: 40, height: capsuleHeight)
            .padding(.vertical, self.capsulePadding)
    }
    
    var body: some View {
        
        GeometryReader { reader in
            
            
            VStack(spacing: 0) {
                VStack(spacing: 0) {
                    self.dragIndicator
                    VStack(content: header)
                        .padding(.bottom, self.separation)
                    VStack(content: content)
                }
                .padding(.horizontal, 10)
                
                
            }
            // Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
            .frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
            .background(Color.white.opacity(0.8))
            .cornerRadius(10)
            .offset(y: reader.size.height - max(self.translation + self.offset, 0))
            .animation(self.animation, value: self.offset)
            .gesture(self.gesture)
            
        }
        .clipped()
    }
    
    
    // MARK: Initializer
    init(
        headerHeight: CGFloat,
        contentHeight: CGFloat,
        separation: CGFloat,
        @ViewBuilder header: @escaping () -> Header,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
        self.contentHeight = contentHeight
        self.separation = separation
        self.header = header
        self.content = content
    }
}

struct ReleaseGesture_Previews: PreviewProvider {

    struct WrapperView: View {
        
        @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
        
        @State private var opened = false
        
        var body: some View {
            ZStack {
                Map(coordinateRegion: $region)
                ReleaseGesture(
                    headerHeight: 25,
                    contentHeight: 300,
                    separation: 30,
                    header: {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.black.opacity(0.3))
                            .frame(height: 30)
                    },
                    content: {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Color.orange.opacity(0.2))
                            .frame(width: 300, height: 300)
                        
                    }
                )
            }
            .ignoresSafeArea()
            
        }
    }

    static var previews: some View {
        WrapperView()
    }
}

现在我们将属性切换为opened属性,Binding这样父视图就可以知道底部工作表的状态。

这是更改的代码。

import SwiftUI
import MapKit

struct ReleaseGesture<Header: View, Content: View>: View {
    
    // MARK: Init properties
    
    // Binding property that shares the state of the sheet to the parent view
    @Binding private var opened: Bool
    
    // Height of the provided header view
    let headerHeight: CGFloat
    
    // Height of the provided content view
    let contentHeight: CGFloat
    
    // The spacing between the header and the content
    let separation: CGFloat
    
    let header: () -> Header
    let content: () -> Content
    
    
    // MARK: State
    @GestureState private var translation: CGFloat = 0
    
    
    // MARK: Constants
    let capsuleHeight: CGFloat = 5
    let capsulePadding: CGFloat = 5
    
    
    // MARK: Computed properties
    
    // The current static value that is always taken into account to compute the sheet's position
    private var offset: CGFloat {
        self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
    }
    
    // Gesture used for the snap animation
    private var gesture: some Gesture {
        DragGesture()
            .updating(self.$translation) { value, state, transaction in
                state = -value.translation.height
            }
            .onEnded {_ in
                self.opened.toggle()
            }
    }
    
    // Animation used when the drag stops
    private var animation: Animation {
        .spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
    }


    // Drag indicator used to indicate the user can drag the sheet
    private var dragIndicator: some View {
        Capsule()
            .fill(Color.gray.opacity(0.4))
            .frame(width: 40, height: capsuleHeight)
            .padding(.vertical, self.capsulePadding)
    }
    
    var body: some View {
        
        GeometryReader { reader in
            
            
            VStack(spacing: 0) {
                VStack(spacing: 0) {
                    self.dragIndicator
                    VStack(content: header)
                        .padding(.bottom, self.separation)
                    VStack(content: content)
                }
                .padding(.horizontal, 10)
                
                
            }
            // Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
            .frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
            .background(Color.white.opacity(0.8))
            .cornerRadius(10)
            .offset(y: reader.size.height - max(self.translation + self.offset, 0))
            .animation(self.animation, value: self.offset)
            .gesture(self.gesture)
            
        }
        .clipped()
    }
    
    
    // MARK: Initializer
    init(
        opened: Binding<Bool>,
        headerHeight: CGFloat,
        contentHeight: CGFloat,
        separation: CGFloat,
        @ViewBuilder header: @escaping () -> Header,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._opened = opened
        self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
        self.contentHeight = contentHeight
        self.separation = separation
        self.header = header
        self.content = content
    }
}

struct ReleaseGesture_Previews: PreviewProvider {

    struct WrapperView: View {
        
        @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
        
        @State private var opened = false
        
        var body: some View {
            ZStack {
                Map(coordinateRegion: $region)
                ReleaseGesture(
                    opened: self.$opened,
                    headerHeight: 25,
                    contentHeight: 300,
                    separation: 30,
                    header: {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.black.opacity(0.3))
                            .frame(height: 30)
                    },
                    content: {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Color.orange.opacity(0.2))
                            .frame(width: 300, height: 300)
                        
                    }
                )
            }
            .ignoresSafeArea()
            
        }
    }

    static var previews: some View {
        WrapperView()
    }
}

}

但是,如果您在 XCode 中运行预览,您会看到当拖动手势停止时,工作表不会从我们抬起手指的位置重新回到“打开”状态,而是从其初始位置开始动画。

奇怪的是,如果您尝试在 iPhone 或模拟器中运行它,它的行为与预期的一样,就像在第一个示例中一样。尽管我尝试在更“复杂”的应用程序中使用该代码(它有一个 TabView 和其他一些东西)并且出现了相同的错误

任何想法为什么会发生这种情况?

这是在真实 iPhone 中测试的真实应用程序中使用的示例视图的示例。您会看到该错误非常明显。

视频: https ://imgur.com/a/RFZbqbD

标签: swiftxcodeswiftui

解决方案


实际上,我在这里看到了一些必须澄清的误解,并且可能应该重新考虑最初的解决方案。绑定不是与 parent 共享的状态,它是指向 parent 的 state 持有事实源的链接,因此您的视图变得依赖于 parent 在状态更改时刷新它的能力,这并不总是可靠的(或稳定的,或持久的等),特别是在不同的视图层次结构中(如工作表、UIKit 后端等)。更改绑定您不会直接刷新视图(与更改自己的状态相反),即使您的视图取决于绑定中的值,而是更改父状态,这可能会或可能不会更新您的视图。最终确定-您暗示的本质上不是可靠的方法,而您实际上观察到了这一点。

替代解决方案:使用ObsevableObject/ObservedObject视图模型模式。

使用 Xcode 12.4 / iOS 14.4 测试

演示

import MapKit

class ReleaseGestureVM: ObservableObject {
    @Published var opened: Bool = false
}

struct ReleaseGesture<Header: View, Content: View>: View {

    // MARK: Init properties
    @ObservedObject var vm: ReleaseGestureVM

    // Height of the provided header view
    let headerHeight: CGFloat

    // Height of the provided content view
    let contentHeight: CGFloat

    // The spacing between the header and the content
    let separation: CGFloat

    let header: () -> Header
    let content: () -> Content


    // MARK: State
    @GestureState private var translation: CGFloat = 0


    // MARK: Constants
    let capsuleHeight: CGFloat = 5
    let capsulePadding: CGFloat = 5


    // MARK: Computed properties

    // The current static value that is always taken into account to compute the sheet's position
    private var offset: CGFloat {
        self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
    }

    // Gesture used for the snap animation
    private var gesture: some Gesture {
        DragGesture()
            .updating(self.$translation) { value, state, transaction in
                state = -value.translation.height
            }
            .onEnded {_ in
                self.vm.opened.toggle()
            }
    }

    // Animation used when the drag stops
    private var animation: Animation {
        .spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
    }


    // Drag indicator used to indicate the user can drag the sheet
    private var dragIndicator: some View {
        Capsule()
            .fill(Color.gray.opacity(0.4))
            .frame(width: 40, height: capsuleHeight)
            .padding(.vertical, self.capsulePadding)
    }

    var body: some View {

        GeometryReader { reader in


            VStack(spacing: 0) {
                VStack(spacing: 0) {
                    self.dragIndicator
                    VStack(content: header)
                        .padding(.bottom, self.separation)
                    VStack(content: content)
                }
                .padding(.horizontal, 10)


            }
            // Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
            .frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
            .background(Color.white.opacity(0.8))
            .cornerRadius(10)
            .offset(y: reader.size.height - max(self.translation + self.offset, 0))
            .animation(self.animation, value: self.offset)
            .gesture(self.gesture)

        }
        .clipped()
    }


    // MARK: Initializer
    init(
        vm: ReleaseGestureVM,
        headerHeight: CGFloat,
        contentHeight: CGFloat,
        separation: CGFloat,
        @ViewBuilder header: @escaping () -> Header,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.vm = vm
        self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
        self.contentHeight = contentHeight
        self.separation = separation
        self.header = header
        self.content = content
    }
}

struct ReleaseGesture_Previews: PreviewProvider {

    struct WrapperView: View {

        @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))

        @StateObject private var vm = ReleaseGestureVM()

        var body: some View {
            ZStack {
                Map(coordinateRegion: $region)
                ReleaseGesture(
                    vm: self.vm,
                    headerHeight: 25,
                    contentHeight: 300,
                    separation: 30,
                    header: {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.black.opacity(0.3))
                            .frame(height: 30)
                    },
                    content: {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Color.orange.opacity(0.2))
                            .frame(width: 300, height: 300)

                    }
                )
            }
            .ignoresSafeArea()

        }
    }

    static var previews: some View {
        WrapperView()
    }
}

推荐阅读