如何从ViewModel为View中的@State属性赋值?(分页触底场景)
问题
我有一个具备基础列表功能的电影列表视图,希望在分页到达最后一页时显示提示弹窗。当前使用reachedLastPage属性控制弹窗显示,viewModel.state是一个枚举,其中.movies关联值包含moreRemaining属性,用于标识是否还有更多分页数据。需要在moreRemaining变为false时,将reachedLastPage设为true以触发弹窗,请问最优实现方式是什么?
import SwiftUI import SwiftUIRefresh struct MovieListingView<T>: View where T: BaseMoviesListViewModel { @ObservedObject var viewModel: T @State var title: String @State var reachedLastPage: Bool = false var body: some View { NavigationView { ZStack { switch viewModel.state { case .loading: LoadingView(title: "Loading Movies...") .onAppear { fetchMovies() } case .error(let error): ErrorView(message: error.localizedDescription, buttonTitle: "Retry") { fetchMovies() } case .noData: Text("No data") .multilineTextAlignment(.center) .font(.system(size: 20)) case .movies(let data): List { ForEach(data.movies) { movie in NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) { MovieViewRow(movie: movie) .onAppear { if movie == data.movies.last && data.moreRemaining { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { fetchMovies() } } } } if movie == data.movies.last && data.moreRemaining { HStack { Spacer() ActivityIndicator(isAnimating: .constant(data.moreRemaining)) Spacer() } } } }.pullToRefresh(isShowing: .constant(data.isRefreshing)) { print("Refresheeeee") DispatchQueue.main.asyncAfter(deadline: .now() + 1) { refreshMovies() } } } } .navigationViewStyle(.stack) .navigationBarTitle("\(title)", displayMode: .inline) .alert(isPresented: $reachedLastPage) { Alert(title: Text("You have reached to the end of the list.")) } } } private func fetchMovies() { viewModel.trigger(.fetchMovies(false)) } private func refreshMovies() { viewModel.trigger(.fetchMovies(true)) } }
最优实现方案
针对这个需求,有两种符合SwiftUI响应式设计的实现方式,分别适合不同场景:
方式一:视图层直接监听状态变化(快速实现)
在视图中通过onChange修饰符监听viewModel.state的变化,当检测到.movies状态下moreRemaining变为false时触发弹窗,同时添加标记避免重复弹窗。
修改后的完整代码:
import SwiftUI import SwiftUIRefresh struct MovieListingView<T>: View where T: BaseMoviesListViewModel { @ObservedObject var viewModel: T @State var title: String @State var reachedLastPage: Bool = false // 标记是否已经显示过末尾弹窗,避免重复触发 @State private var hasShownEndAlert = false var body: some View { NavigationView { ZStack { switch viewModel.state { case .loading: LoadingView(title: "加载电影中...") .onAppear { fetchMovies() } case .error(let error): ErrorView(message: error.localizedDescription, buttonTitle: "重试") { fetchMovies() } case .noData: Text("暂无数据") .multilineTextAlignment(.center) .font(.system(size: 20)) case .movies(let data): List { ForEach(data.movies) { movie in NavigationLink(destination: LazyView(MovieDetailView(viewModel: MovieDetailViewModel(id: movie.id)))) { MovieViewRow(movie: movie) .onAppear { if movie == data.movies.last && data.moreRemaining { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { fetchMovies() } } } } if movie == data.movies.last && data.moreRemaining { HStack { Spacer() ActivityIndicator(isAnimating: .constant(data.moreRemaining)) Spacer() } } } }.pullToRefresh(isShowing: .constant(data.isRefreshing)) { print("开始刷新") DispatchQueue.main.asyncAfter(deadline: .now() + 1) { refreshMovies() // 下拉刷新时重置弹窗标记,允许再次触发 hasShownEndAlert = false } } } } .navigationViewStyle(.stack) .navigationBarTitle("\(title)", displayMode: .inline) .alert(isPresented: $reachedLastPage) { Alert(title: Text("已到达列表末尾")) { Button("确定") { // 弹窗关闭后重置标记,避免后续无法触发 hasShownEndAlert = false } } } // 监听ViewModel状态变化,触发弹窗逻辑 .onChange(of: viewModel.state) { newState in guard case .movies(let data) = newState else { return } // 无更多数据且未显示过弹窗时触发 if !data.moreRemaining && !hasShownEndAlert { hasShownEndAlert = true reachedLastPage = true } } } } private func fetchMovies() { viewModel.trigger(.fetchMovies(false)) } private func refreshMovies() { viewModel.trigger(.fetchMovies(true)) } }
方式二:将逻辑移至ViewModel(MVVM架构推荐)
更规范的做法是在ViewModel中添加@Published属性管理弹窗状态,当moreRemaining变为false时由ViewModel主动发送触发信号,视图仅负责监听和展示。
ViewModel修改示例:
class BaseMoviesListViewModel: ObservableObject { enum State { case loading case error(Error) case noData case movies(MovieData) } struct MovieData { var movies: [Movie] var moreRemaining: Bool var isRefreshing: Bool } enum Action { case fetchMovies(Bool) } @Published var state: State = .loading @Published var showEndAlert: Bool = false private var hasTriggeredEndAlert = false func trigger(_ action: Action) { switch action { case .fetchMovies(let isRefresh): // 模拟数据请求逻辑(替换为真实网络请求) fetchMockData(isRefresh: isRefresh) { [weak self] result in guard let self = self else { return } switch result { case .success(let data): self.state = .movies(data) // 无更多数据且未触发过弹窗时,设置弹窗状态 if !data.moreRemaining && !self.hasTriggeredEndAlert { self.hasTriggeredEndAlert = true self.showEndAlert = true } // 下拉刷新重置标记 if isRefresh { self.hasTriggeredEndAlert = false } case .failure(let error): self.state = .error(error) } } } } // 模拟数据请求方法 private func fetchMockData(isRefresh: Bool, completion: @escaping (Result<MovieData, Error>) -> Void) { // 此处替换为真实网络请求逻辑 DispatchQueue.main.asyncAfter(deadline: .now() + 1) { let mockMovies = (0..<20).map { Movie(id: $0, title: "电影\($0)") } let data = MovieData(movies: mockMovies, moreRemaining: false, isRefreshing: false) completion(.success(data)) } } // 重置弹窗标记的方法(供视图调用) func resetEndAlertFlag() { hasTriggeredEndAlert = false } }
视图层修改:
struct MovieListingView<T>: View where T: BaseMoviesListViewModel { @ObservedObject var viewModel: T @State var title: String var body: some View { NavigationView { // 原视图内容保持不变... } .alert(isPresented: $viewModel.showEndAlert) { Alert(title: Text("已到达列表末尾")) { Button("确定") { viewModel.resetEndAlertFlag() } } } } // 原fetchMovies、refreshMovies方法不变... }
方案对比
- 方式一:无需修改ViewModel,快速落地,适合小型项目或临时需求。
- 方式二:遵循MVVM架构,将业务逻辑集中在ViewModel,视图仅负责UI展示,代码更易维护和测试,推荐用于中大型项目。
内容的提问来源于stack exchange,提问作者Anirudha Mahale
相关产品推荐
相关产品推荐

