Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save scroll position in timeline #2459

Draft
wants to merge 1 commit into
base: release_1.10
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions damus/Views/Timeline/InnerTimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct InnerTimelineView: View {
return [.wide]
}

var body: some View {
var main_content: some View {
LazyVStack(spacing: 0) {
let events = self.events.events
if events.isEmpty {
Expand All @@ -37,6 +37,12 @@ struct InnerTimelineView: View {
let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
// Since NoteId is a struct (therefore a value type, not a reference type),
// assigning the id to a variable in Swift will cause the memory contents to be copied over,
// therefore ensuring we will *own* this piece of memory, reducing the risk of being rugged by Ndb today and in future as the codebase evolves.
// This is a 32-byte copy operation without any parsing, so it should in theory not regress performance significantly.
// Thanks for coming to my TED talk about this one line of code.
let ev_id = ev.id
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

events held in timelines are owned data! they are not pointers to nostrdb. that would be bad, since the data would be outside of a transaction.

when we switch to the local relay model, timeline events will be lists of u64 primary keys. This is how notedeck works.

Copying an ID will probably be what we have to do when we switch to the local relay model, so this LGTM!

let ind = tup.1
EventView(damus: state, event: ev, options: event_options)
.onTapGesture {
Expand All @@ -45,6 +51,7 @@ struct InnerTimelineView: View {
state.nav.push(route: Route.Thread(thread: thread))
}
.padding(.top, 7)
.id(BlockID.note(ev_id))
.onAppear {
let to_preload =
Array([indexed[safe: ind+1]?.0,
Expand All @@ -62,8 +69,48 @@ struct InnerTimelineView: View {
}
}
}
//.padding(.horizontal)
}

var body: some View {
if #available(iOS 17.0, *) {
self.main_content
.scrollTargetLayout() // This helps us keep track of the scroll position by telling SwiftUI which VStack we should use for scroll position ids
} else {
// Fallback on earlier versions
self.main_content
}
}

enum BlockID: RawRepresentable, Hashable, Codable {
case top
case note(NoteId)

// MARK: - Custom RawRepresentable implementation
// Note: String RawRepresentable implementation is needed to be used with SceneStorage
// Note: Declaring enum as a `String` for synthesized protocol conformance does not work as this is an enum with associated types

typealias RawValue = String

var rawValue: String {
switch self {
case .top:
return "top"
case .note(let note_id):
return "note:\(note_id.hex())"
}
}

init?(rawValue: String) {
let components = rawValue.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if components.count == 2 && components[0] == "note" {
let second_component = String(components[1])
guard let note_id = NoteId.init(hex: second_component) else { return nil }
self = .note(note_id)
} else if components[0] == "top" {
self = .top
}
return nil
}
}
}

Expand Down
78 changes: 50 additions & 28 deletions damus/Views/TimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ struct TimelineView<Content: View>: View {
let filter: (NostrEvent) -> Bool
let content: Content?
let apply_mute_rules: Bool
// Note: SceneStorage persists through a session. If user completely quits the app, scroll position is not persisted.
@SceneStorage("scroll_position") var scroll_position: InnerTimelineView.BlockID = .top

init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
Expand All @@ -28,39 +30,59 @@ struct TimelineView<Content: View>: View {
}

var body: some View {
MainContent
ScrollViewReader { scroller in
self.MainContent(scroller: scroller)
}
.onAppear {
events.flush()
}
}

var MainContent: some View {
ScrollViewReader { scroller in
ScrollView {
if let content {
content
}

Color.white.opacity(0)
.id("startblock")
.frame(height: 1)

InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background(GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
})
}
//.buttonStyle(BorderlessButtonStyle())
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.should_queue = false
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
func MainContent(scroller: ScrollViewProxy) -> some View {
if #available(iOS 17.0, *) {
return self.MainScrollView(scroller: scroller)
.scrollPosition(id:
// A custom Binding is needed to reconciliate incompatible types between this call and @SceneStorage
Binding(
get: {
return self.scroll_position as InnerTimelineView.BlockID?
},
set: { newValue in
let newValueToSet = newValue ?? .top
self.scroll_position = newValueToSet
}
), anchor: .top)
} else {
return self.MainScrollView(scroller: scroller)
}
}

func MainScrollView(scroller: ScrollViewProxy) -> some View {
ScrollView {
if let content {
content
}

Color.white.opacity(0)
.id(InnerTimelineView.BlockID.top)
.frame(height: 1)

InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background(GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
})
}
.onAppear {
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.should_queue = false
withAnimation {
self.scroll_position = .top
}
}
}
}
Expand Down