Merge Fix unwanted draft auto-scrolls
Daniel D’Aquino (1):
Fix unwanted auto-scrolls related to draft saving mechanism
This commit is contained in:
@@ -71,9 +71,12 @@ struct PostView: View {
|
||||
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
|
||||
@State var newCursorIndex: Int?
|
||||
@State var textHeight: CGFloat? = nil
|
||||
@State var saved_state: SaveState = .needs_saving()
|
||||
/// A timer that helps us add a delay between when changes occur and when they are saved persistently (to avoid too many disk writes and a jittery save indicator)
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
/// Manages the auto-save logic for drafts.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This intentionally does _not_ use `@ObservedObject` or `@StateObject` because observing changes causes unwanted automatic scrolling to the text cursor on each save state update.
|
||||
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
|
||||
|
||||
@State var preUploadedMedia: [PreUploadedMedia] = []
|
||||
|
||||
@@ -89,16 +92,6 @@ struct PostView: View {
|
||||
let placeholder_messages: [String]
|
||||
let initial_text_suffix: String?
|
||||
|
||||
enum SaveState: Equatable {
|
||||
/// The draft has been modified and needs saving.
|
||||
/// Saving should occur in N seconds
|
||||
case needs_saving(seconds_remaining: Int = 3)
|
||||
/// A saving operation is in progress
|
||||
case saving
|
||||
/// The draft has been saved to disk.
|
||||
case saved
|
||||
}
|
||||
|
||||
init(
|
||||
action: PostAction,
|
||||
damus_state: DamusState,
|
||||
@@ -111,6 +104,7 @@ struct PostView: View {
|
||||
self.prompt_view = prompt_view
|
||||
self.placeholder_messages = placeholder_messages ?? [POST_PLACEHOLDER]
|
||||
self.initial_text_suffix = initial_text_suffix
|
||||
self.autoSaveModel = AutoSaveIndicatorView.AutoSaveViewModel(save: { damus_state.drafts.save(damus_state: damus_state) })
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -183,33 +177,12 @@ struct PostView: View {
|
||||
})
|
||||
}
|
||||
|
||||
var save_state_indicator: some View {
|
||||
HStack {
|
||||
switch saved_state {
|
||||
case .needs_saving:
|
||||
EmptyView()
|
||||
.accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
|
||||
case .saving:
|
||||
ProgressView()
|
||||
.accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
|
||||
case .saved:
|
||||
Image(systemName: "checkmark")
|
||||
.accessibilityHidden(true)
|
||||
Text("Saved", comment: "Small label indicating that the user's draft has been saved to storage")
|
||||
.accessibilityLabel(NSLocalizedString("Your draft has been saved to storage", comment: "Accessibility label indicating that a user's post draft has been saved, meant only for visually impaired users"))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
var AttachmentBar: some View {
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
ImageButton
|
||||
CameraButton
|
||||
Spacer()
|
||||
self.save_state_indicator
|
||||
AutoSaveIndicatorView(saveViewModel: self.autoSaveModel)
|
||||
}
|
||||
.disabled(uploading_disabled)
|
||||
}
|
||||
@@ -263,13 +236,13 @@ struct PostView: View {
|
||||
guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else {
|
||||
self.post = NSMutableAttributedString("")
|
||||
self.uploadedMedias = []
|
||||
self.saved_state = .needs_saving()
|
||||
self.autoSaveModel.markNothingToSave() // We should not save empty drafts.
|
||||
return false
|
||||
}
|
||||
|
||||
self.uploadedMedias = draft.media
|
||||
self.post = draft.content
|
||||
self.saved_state = .saved
|
||||
self.autoSaveModel.markSaved() // The draft we just loaded is saved to memory. Mark it as such.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -287,7 +260,7 @@ struct PostView: View {
|
||||
let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString)
|
||||
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
|
||||
}
|
||||
self.saved_state = .needs_saving()
|
||||
self.autoSaveModel.needsSaving()
|
||||
}
|
||||
|
||||
var TextEntry: some View {
|
||||
@@ -602,21 +575,6 @@ struct PostView: View {
|
||||
preUploadedMedia.removeAll()
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { time in
|
||||
switch self.saved_state {
|
||||
case .needs_saving(seconds_remaining: let seconds_remaining):
|
||||
if seconds_remaining <= 0 {
|
||||
self.saved_state = .saving
|
||||
damus_state.drafts.save(damus_state: damus_state)
|
||||
self.saved_state = .saved
|
||||
}
|
||||
else {
|
||||
self.saved_state = .needs_saving(seconds_remaining: seconds_remaining - 1)
|
||||
}
|
||||
case .saving, .saved:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
136
damus/Views/Posting/AutoSaveIndicatorView.swift
Normal file
136
damus/Views/Posting/AutoSaveIndicatorView.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// AutoSaveIndicatorView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-12.
|
||||
//
|
||||
import SwiftUI
|
||||
|
||||
/// A small indicator view to indicate whether an item has been saved or not.
|
||||
///
|
||||
/// This view uses and observes an `AutoSaveViewModel`.
|
||||
struct AutoSaveIndicatorView: View {
|
||||
@ObservedObject var saveViewModel: AutoSaveViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
switch saveViewModel.savedState {
|
||||
case .needsSaving, .nothingToSave:
|
||||
EmptyView()
|
||||
.accessibilityHidden(true) // Probably no need to show this to users with visual impairment, might be too noisy.
|
||||
case .saving:
|
||||
ProgressView()
|
||||
.accessibilityHidden(true) // Probably no need to show this to users with visual impairment, might be too noisy.
|
||||
case .saved:
|
||||
Image(systemName: "checkmark")
|
||||
.accessibilityHidden(true)
|
||||
Text("Saved", comment: "Small label indicating that the user's draft has been saved to storage.")
|
||||
.accessibilityLabel(NSLocalizedString("Your draft has been saved to storage.", comment: "Accessibility label indicating that a user's post draft has been saved, meant to be read by screen reading technology."))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension AutoSaveIndicatorView {
|
||||
/// A simple data structure to model the saving state of an item that can be auto-saved every few seconds.
|
||||
enum SaveState: Equatable {
|
||||
/// There is nothing to save (e.g. A new empty item was just created, an item was just loaded)
|
||||
case nothingToSave
|
||||
/// The item has been modified and needs saving.
|
||||
/// Saving should occur in N seconds.
|
||||
case needsSaving(secondsRemaining: Int)
|
||||
/// A saving operation is in progress.
|
||||
case saving
|
||||
/// The item has been saved to disk.
|
||||
case saved
|
||||
}
|
||||
|
||||
/// Models an auto-save mechanism, which automatically saves an item after N seconds.
|
||||
///
|
||||
/// # Implementation notes
|
||||
///
|
||||
/// - This runs on the main actor because running this on other actors causes issues with published properties.
|
||||
/// - Running on one actor helps ensure thread safety.
|
||||
@MainActor
|
||||
class AutoSaveViewModel: ObservableObject {
|
||||
/// The delay between the time something is marked as needing to save, and the actual saving operation.
|
||||
///
|
||||
/// Should be low enough that the user does not lose significant progress, and should be high enough to avoid unnecessary disk writes and jittery, stress-inducing behavior
|
||||
let saveDelay: Int
|
||||
/// The current state of this model
|
||||
@Published private(set) var savedState: SaveState
|
||||
/// A timer which counts down the time to save the item
|
||||
private var timer: Timer?
|
||||
/// The function that performs the actual save operation
|
||||
var save: () async -> Void
|
||||
|
||||
|
||||
// MARK: Init/de-init
|
||||
|
||||
/// Initializes a new auto-save model
|
||||
/// - Parameters:
|
||||
/// - save: The function that performs the save operation
|
||||
/// - initialState: Optional initial state
|
||||
/// - saveDelay: The time delay between the item is marked as needing to be saved, and the actual save operation — denoted in seconds.
|
||||
init(save: @escaping () async -> Void, initialState: SaveState = .nothingToSave, saveDelay: Int = 3) {
|
||||
self.saveDelay = saveDelay
|
||||
self.savedState = initialState
|
||||
self.save = save
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { timer in
|
||||
Task { await self.tick() } // Task { await ... } ensures the function is properly run on the main actor and avoids thread-safety issues
|
||||
})
|
||||
self.timer = timer
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let timer = self.timer {
|
||||
timer.isValid ? timer.invalidate() : ()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Internal logic
|
||||
|
||||
/// Runs internal countdown-to-save logic
|
||||
private func tick() async {
|
||||
switch self.savedState {
|
||||
case .needsSaving(secondsRemaining: let secondsRemaining):
|
||||
if secondsRemaining <= 0 {
|
||||
self.savedState = .saving
|
||||
await save()
|
||||
self.savedState = .saved
|
||||
}
|
||||
else {
|
||||
self.savedState = .needsSaving(secondsRemaining: secondsRemaining - 1)
|
||||
}
|
||||
case .saving, .saved, .nothingToSave:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: External interface
|
||||
|
||||
/// Marks item as needing to be saved.
|
||||
/// Call this whenever your item is modified.
|
||||
func needsSaving() {
|
||||
self.savedState = .needsSaving(secondsRemaining: self.saveDelay)
|
||||
}
|
||||
|
||||
/// Marks item as saved.
|
||||
/// Call this when you know the item is already saved (e.g. when loading a saved item from memory).
|
||||
func markSaved() {
|
||||
self.savedState = .saved
|
||||
}
|
||||
|
||||
/// Tells the auto-save logic that there is nothing to be saved.
|
||||
/// Call this when there is nothing to be saved (e.g. when opening a new empty item).
|
||||
func markNothingToSave() {
|
||||
self.savedState = .nothingToSave
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user