jumpy scroll 4
This commit is contained in:
+90
-53
@@ -15,10 +15,10 @@ final class ChatModel {
|
||||
let builder: TranscriptBuilder
|
||||
private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)?
|
||||
private(set) var fetching = false
|
||||
private(set) var olderGeneration = 0
|
||||
private(set) var older: [ByreEvent]?
|
||||
private(set) var hasUnmerged = false
|
||||
var scannerDrop: CGFloat = 0
|
||||
@ObservationIgnored var stuck = true
|
||||
@ObservationIgnored var prepended = false
|
||||
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
|
||||
@ObservationIgnored private var offs: [() -> Void] = []
|
||||
@ObservationIgnored private var focusStart = true
|
||||
@@ -223,23 +223,30 @@ final class ChatModel {
|
||||
}
|
||||
|
||||
func loadOlder() {
|
||||
if fetching || !hasOlder {
|
||||
if fetching || older != nil || !hasOlder {
|
||||
return
|
||||
}
|
||||
fetching = true
|
||||
Task {
|
||||
do {
|
||||
let events = try await store.loadOlderLead()
|
||||
let first = builder.root.rows.first?.id
|
||||
builder.prepend(events)
|
||||
if let first {
|
||||
requestScroll(first, .top)
|
||||
}
|
||||
older = try await store.loadOlderLead()
|
||||
} catch {
|
||||
log.error("load earlier events: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
fetching = false
|
||||
olderGeneration += 1
|
||||
}
|
||||
}
|
||||
|
||||
func prependOlder() {
|
||||
guard let events = older else {
|
||||
return
|
||||
}
|
||||
older = nil
|
||||
let n = builder.root.rows.count
|
||||
builder.prepend(events)
|
||||
prepended = builder.root.rows.count > n || !hasOlder
|
||||
if !prepended {
|
||||
loadOlder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +497,7 @@ private struct ScrollMetrics: Equatable {
|
||||
let content: CGFloat
|
||||
let visible: CGFloat
|
||||
let distance: CGFloat
|
||||
let offset: CGFloat
|
||||
}
|
||||
|
||||
private struct TranscriptScroll: View {
|
||||
@@ -502,23 +510,16 @@ private struct TranscriptScroll: View {
|
||||
var body: some View {
|
||||
let running = model.running
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if model.key == nil {
|
||||
Muted(text: model.empty)
|
||||
} else {
|
||||
if model.loading {
|
||||
Muted(text: "loading…")
|
||||
}
|
||||
if model.fetching {
|
||||
if model.hasOlder || model.older != nil {
|
||||
Muted(text: "loading earlier…")
|
||||
}
|
||||
if model.hasOlder && !model.fetching {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("older-\(model.olderGeneration)")
|
||||
.onAppear {
|
||||
model.loadOlder()
|
||||
}
|
||||
.opacity(model.fetching || model.older != nil ? 1 : 0)
|
||||
}
|
||||
ForEach(model.builder.root.rows) { row in
|
||||
RowView(row: row, model: model, nested: false)
|
||||
@@ -529,19 +530,28 @@ private struct TranscriptScroll: View {
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(16)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if running && model.key != nil {
|
||||
ScannerOverlay(model: model, color: accent)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if running && model.key != nil {
|
||||
Scanner(color: accent)
|
||||
.frame(width: 84, height: 2)
|
||||
.offset(y: 8)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.scrollPosition($position)
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.onScrollGeometryChange(for: ScrollMetrics.self) { g in
|
||||
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY)
|
||||
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y)
|
||||
} action: { old, new in
|
||||
model.scannerDrop = new.distance
|
||||
if model.prepended && new.content != old.content {
|
||||
model.prepended = false
|
||||
position.scrollTo(y: new.offset + new.content - old.content)
|
||||
return
|
||||
}
|
||||
if new.offset < new.visible {
|
||||
model.loadOlder()
|
||||
}
|
||||
if scrolling {
|
||||
model.stuck = new.distance < 80
|
||||
return
|
||||
@@ -552,6 +562,14 @@ private struct TranscriptScroll: View {
|
||||
}
|
||||
.onScrollPhaseChange { _, phase in
|
||||
scrolling = phase.isScrolling
|
||||
if !scrolling {
|
||||
model.prependOlder()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.older == nil) {
|
||||
if !scrolling {
|
||||
model.prependOlder()
|
||||
}
|
||||
}
|
||||
.onChange(of: model.builder.pending.last?.id) { old, new in
|
||||
guard let new, new > old ?? 0 else {
|
||||
@@ -572,39 +590,58 @@ private struct TranscriptScroll: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScannerOverlay: View {
|
||||
let model: ChatModel
|
||||
private struct Scanner: NSViewRepresentable {
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Scanner(color: color)
|
||||
.padding(.leading, 16)
|
||||
.padding(.bottom, 8)
|
||||
.offset(y: model.scannerDrop)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
.clipped()
|
||||
.allowsHitTesting(false)
|
||||
func makeNSView(context: Context) -> ScannerView {
|
||||
ScannerView()
|
||||
}
|
||||
|
||||
func updateNSView(_ v: ScannerView, context: Context) {
|
||||
let c = NSColor(color)
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor]
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private struct Scanner: View {
|
||||
let color: Color
|
||||
private final class ScannerView: NSView {
|
||||
let glow = CAGradientLayer()
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { context in
|
||||
Canvas { gc, _ in
|
||||
let t = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 1.2
|
||||
let leg = t < 1 ? t : 2 - t
|
||||
let center = 15 + 54 * (1 - cos(.pi * leg)) / 2
|
||||
for x in stride(from: 0.0, through: 80, by: 5) {
|
||||
let alpha = max(0, 1 - abs(x + 1 - center) / 15)
|
||||
if alpha > 0 {
|
||||
gc.fill(Path(CGRect(x: x, y: 0, width: 2, height: 2)), with: .color(color.opacity(alpha)))
|
||||
}
|
||||
}
|
||||
}
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
let dots = CALayer()
|
||||
dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2)
|
||||
for x in stride(from: 0.0, through: 80, by: 5) {
|
||||
let dot = CALayer()
|
||||
dot.frame = CGRect(x: x, y: 0, width: 2, height: 2)
|
||||
dot.backgroundColor = NSColor.black.cgColor
|
||||
dots.addSublayer(dot)
|
||||
}
|
||||
.frame(width: 84, height: 2)
|
||||
glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2)
|
||||
glow.position = CGPoint(x: 15, y: 1)
|
||||
glow.startPoint = CGPoint(x: 0, y: 0.5)
|
||||
glow.endPoint = CGPoint(x: 1, y: 0.5)
|
||||
let strip = CALayer()
|
||||
strip.frame = dots.frame
|
||||
strip.mask = dots
|
||||
strip.addSublayer(glow)
|
||||
layer?.addSublayer(strip)
|
||||
let sweep = CABasicAnimation(keyPath: "position.x")
|
||||
sweep.fromValue = 15
|
||||
sweep.toValue = 69
|
||||
sweep.duration = 1.2
|
||||
sweep.autoreverses = true
|
||||
sweep.repeatCount = .infinity
|
||||
sweep.timingFunction = CAMediaTimingFunction(controlPoints: 0.37, 0, 0.63, 1)
|
||||
sweep.isRemovedOnCompletion = false
|
||||
glow.add(sweep, forKey: "sweep")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user