Вопрос или проблема
Я сталкиваюсь с двумя проблемами с List в SwiftUI, и я прикрепил GIF и изображение для справки:
Автоматическая прокрутка вверх при обновлении данных:
Каждый раз, когда я обновляю свои данные (например, перезагружаю или изменяю список), List автоматически прокручивается вверх. Я предпочел бы, чтобы он оставался на текущей позиции прокрутки. Как я могу предотвратить это поведение и сохранить стабильное положение прокрутки?
Действия с проводами затрагивают все элементы:
Я реализовал действия с проводами, используя .swipeActions в своем List. Однако, когда я провожу по любому элементу, действия с проводами, похоже, применяются ко всем строкам в списке, а не только к той, с которой я взаимодействовал. Как я могу ограничить действия с проводами только конкретным элементом, по которому я провожу?
Дополнительная информация:
Я использую List, обернутый в NavigationView.
Данные обновляются динамически, так как они получаются из удаленного API.
Для действий с проводами я использую модификатор .swipeActions.
private var historySection: some View {
VStack(spacing: 0) {
if viewModel.isLoading || viewModel.isLoadingMore {
ForEach(0..<3, id: \.self) { _ in
SkeletonHistorySectionView()
}
} else {
ForEach(groupedItems, id: \.key) { date, items in
VStack(spacing: 0) {
Text(viewModel.dateFormatter.string(from: date))
.font(.footnote)
.foregroundColor(.gray)
.padding(.leading)
.frame(maxWidth: .infinity)
.padding(.top)
ForEach(items) { item in
HistoryRow(item: item, dateFormatter: viewModel.dateFormatter, timeFormatter: viewModel.timeFormatter, categoryDictionary: categoryDictionary, accountDictionary: accountDictionary, viewModel: viewModel)
.onTapGesture {
selectedItem = item
showingDetailSheet.toggle()
}
.swipeActions {
Button {
//ТЕСТ
} label: {
Label("ТЕСТ.", systemImage: "pencil")
}
.tint(.accentColor)
}
}
.padding(.bottom, 8)
}
}
}
}
}
struct HistoryRow: View {
let item: HistoryItem
let dateFormatter: DateFormatter
let timeFormatter: DateFormatter
let categoryDictionary: [Int: Category]
let accountDictionary: [Int: Account]
@ObservedObject var viewModel: HistoryViewModel
var body: some View {
HStack {
Image(systemName: item.iconName)
.resizable()
.scaledToFit()
.frame(width: 35, height: 35)
.foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(mainAccountName(for: item.accountId ?? 0))
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Text(formattedAmount)
.font(.headline)
.foregroundColor(item.type == "expense" ? .red : .green)
}
.contentShape(Rectangle())
.padding(.top)
.padding(.bottom)
}
func mainAccountName(for accountId: Int) -> String {
guard let account = accountDictionary[accountId] else { return "Не указан" }
return account.name
}
private var formattedAmount: String {
let amount = item.amount
return NumberFormatterHelper.formatNumberWithSign(item.type == "expense" ? -amount : amount, currency: viewModel.selectedCurrency)
}
}
func loadMoreHistoryData(reset: Bool = false, reportMonth: String, reportCategoryId: Int = 0) async {
if isLoadingMore { return }
isLoadingMore = true
defer { isLoadingMore = false }
if reset {
currentPage = 1
historyItems = []
isAllDataLoaded = false
}
do {
let (newItems, totalIncome, totalExpenses) = try await dataService.getRecentTransactions(page: currentPage, size: pageSize, reportMonth: reportMonth, reportCategoryId: reportCategoryId)
// Сохраняем статистику
self.totalIncome = totalIncome
self.totalExpenses = totalExpenses
DispatchQueue.main.async {
self.historyItems.append(contentsOf: newItems)
self.currentPage += 1
if newItems.count < self.pageSize {
self.isAllDataLoaded = true
}
}
} catch {
DispatchQueue.main.async {
self.errorMessage = error.localizedDescription
}
}
}
func loadMoreHistoryForCategory(reportMonth: String, selectedCategoryId: Int) async {
guard !isLoadingMore, !isAllDataLoaded else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let (newItems, totalIncome, totalExpenses) = try await dataService.getHistoryForCategory(page: currentPage, size: pageSize, reportMonth: reportMonth, reportCategoryId: selectedCategoryId)
// Сохраняем статистику
self.totalIncome = totalIncome
self.totalExpenses = totalExpenses
if newItems.count < pageSize {
isAllDataLoaded = true
}
if !newItems.isEmpty {
historyItems.append(contentsOf: newItems)
currentPage += 1
}
} catch {
print("Ошибка подгрузки истории для категории \(selectedCategoryId) за месяц \(reportMonth): \(error.localizedDescription)")
}
}
private var historySection: some View {
VStack(spacing: 0) {
if viewModel.isLoading || viewModel.isLoadingMore {
ForEach(0..<3, id: \.self) { _ in
SkeletonHistorySectionView()
}
} else {
ForEach(groupedItems, id: \.key) { date, items in
VStack(spacing: 0) {
Text(viewModel.dateFormatter.string(from: date))
.font(.footnote)
.foregroundColor(.gray)
.padding(.leading)
.frame(maxWidth: .infinity)
.padding(.top)
ForEach(items) { item in
HistoryRow(item: item, dateFormatter: viewModel.dateFormatter, timeFormatter: viewModel.timeFormatter, categoryDictionary: categoryDictionary, accountDictionary: accountDictionary, viewModel: viewModel)
.onTapGesture {
selectedItem = item
showingDetailSheet.toggle()
}
.swipeActions {
Button {
//ТЕСТ
} label: {
Label("ТЕСТ.", systemImage: "pencil")
}
.tint(.accentColor)
}
}
.padding(.bottom, 8)
}
}
}
}
}
struct HistoryRow: View {
let item: HistoryItem
let dateFormatter: DateFormatter
let timeFormatter: DateFormatter
let categoryDictionary: [Int: Category]
let accountDictionary: [Int: Account]
@ObservedObject var viewModel: HistoryViewModel
var body: some View {
HStack {
Image(systemName: item.iconName)
.resizable()
.scaledToFit()
.frame(width: 35, height: 35)
.foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(mainAccountName(for: item.accountId ?? 0))
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Text(formattedAmount)
.font(.headline)
.foregroundColor(item.type == "expense" ? .red : .green)
}
.contentShape(Rectangle())
.padding(.top)
.padding(.bottom)
}
func mainAccountName(for accountId: Int) -> String {
guard let account = accountDictionary[accountId] else { return "Не указан" }
return account.name
}
private var formattedAmount: String {
let amount = item.amount
return NumberFormatterHelper.formatNumberWithSign(item.type == "expense" ? -amount : amount, currency: viewModel.selectedCurrency)
}
}
func loadMoreHistoryData(reset: Bool = false, reportMonth: String, reportCategoryId: Int = 0) async {
if isLoadingMore { return }
isLoadingMore = true
defer { isLoadingMore = false }
if reset {
currentPage = 1
historyItems = []
isAllDataLoaded = false
}
do {
let (newItems, totalIncome, totalExpenses) = try await dataService.getRecentTransactions(page: currentPage, size: pageSize, reportMonth: reportMonth, reportCategoryId: reportCategoryId)
// Сохраняем статистику
self.totalIncome = totalIncome
self.totalExpenses = totalExpenses
DispatchQueue.main.async {
self.historyItems.append(contentsOf: newItems)
self.currentPage += 1
if newItems.count < self.pageSize {
self.isAllDataLoaded = true
}
}
} catch {
DispatchQueue.main.async {
self.errorMessage = error.localizedDescription
}
}
}
func loadMoreHistoryForCategory(reportMonth: String, selectedCategoryId: Int) async {
guard !isLoadingMore, !isAllDataLoaded else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let (newItems, totalIncome, totalExpenses) = try await dataService.getHistoryForCategory(page: currentPage, size: pageSize, reportMonth: reportMonth, reportCategoryId: selectedCategoryId)
// Сохраняем статистику
self.totalIncome = totalIncome
self.totalExpenses = totalExpenses
if newItems.count < pageSize {
isAllDataLoaded = true
}
if !newItems.isEmpty {
historyItems.append(contentsOf: newItems)
currentPage += 1
}
} catch {
print("Ошибка подгрузки истории для категории \(selectedCategoryId) за месяц \(reportMonth): \(error.localizedDescription)")
}
}
Ответ или решение
Проблема автопрокрутки списка в SwiftUI при обновлении данных и ограничения действий при свайпе
Ваша ситуация, связанная с использованием компонента List
в SwiftUI, поднимает два основных вопроса: автоматическая прокрутка списка к началу после обновления данных и неправильное поведение кнопок на свайпах, которые применяются ко всем элементам списка. Рассмотрим каждый из этих вопросов более детально.
1. Автопрокрутка списка к началу при обновлении данных
Когда вы обновляете данные вашего списка, он возвращается к прокрутке в верхнюю часть. Это может произойти из-за того, как SwiftUI перерисовывает представление списка при изменении состояния данных.
Решение: Использование State и Identifiable
Чтобы сохранить положение прокрутки, необходимо убедиться, что идентификаторы элементов в списке уникальны и не изменяются, когда обновляются данные. В вашем случае это может быть достигнуто с помощью корректного использования id:
в ForEach
, а также использования @State
для управления списком.
Вот пример корректной реализации:
ForEach(viewModel.historyItems, id: \.id) { item in
HistoryRow(item: item, ...)
}
Также можно использовать ScrollViewReader
для сохранения текущей позиции прокрутки и его восстановления. Вот как это выглядит:
ScrollViewReader { scrollView in
List {
ForEach(viewModel.historyItems, id: \.id) { item in
HistoryRow(item: item ...)
}
}
.onAppear {
// Здесь вы можете управлять сохранением позиции
}
}
Вы можете запоминать позицию прокрутки при загрузке данных, а затем использовать ее для возврата в нужную точку после обновления состояния.
2. Ограничение действии при свайпе только на текущий элемент
Когда вы применяете модификатор .swipeActions
, важно убедиться, что каждое действие относится только к текущему элементу списка, а не ко всем одновременно.
Решение: Передача параметров в swipeActions
Как правило, каждый элемент списка должен иметь свой собственный контекст. Убедитесь, что ваши действия в swipeActions
привязаны к конкретному объекту. Например:
ForEach(viewModel.historyItems, id: \.id) { item in
HistoryRow(item: item ...)
.swipeActions(edge: .trailing) {
Button {
// Действие только для текущего `item`
} label: {
Label("Действие", systemImage: "pencil")
}
.tint(.accentColor)
}
}
Следовательно, если каждое действие связано с конкретным объектом, SwiftUI будет корректно обрабатывать свайпы и применять действия только к тем элементам, к которым вы обращаетесь.
Заключение
Обработка обновления данных и правильная реализация действий на свайп являются важными аспектами для создания эффективного пользовательского интерфейса в SwiftUI. Использование уникальных идентификаторов, а также применение ScrollViewReader
позволит избежать проблем с прокруткой, а правильное связывание действий с конкретными элементами списка улучшит взаимодействие с пользователем.
Надеюсь, это поможет вам решить ваши проблемы в приложении. Удачи в разработке!