on master help sign in
Implement message pagination and smart scroll-to-bottom with lazy loading
442423ciko
client/Styles.fs+16
@@ -43,6 +43,22 @@ module Styles =
43 .Name("PART_ContentPresenter"))
43 .Name("PART_ContentPresenter"))
44 [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
44 [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
45
45
46+ let noListBoxSelection () =
47+ [ style
48+ (_.OfType<ListBoxItem>()
49+ .Class(":selected")
50+ .Template()
51+ .OfType<ContentPresenter>()
52+ .Name("PART_ContentPresenter"))
53+ [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ]
54+ style
55+ (_.OfType<ListBoxItem>()
56+ .Class(":pointerover")
57+ .Template()
58+ .OfType<ContentPresenter>()
59+ .Name("PART_ContentPresenter"))
60+ [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ] ]
61+
46 let private bitmapCache =
62 let private bitmapCache =
47 System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
63 System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
48
64
client/ViewChat.fs+115−34
@@ -12,6 +12,73 @@ module ViewChat =
12 open Buttons
12 open Buttons
13 open TextFields
13 open TextFields
14
14
15+ let private scrollRef = Fabulous.ViewRef<Avalonia.Controls.ScrollViewer>()
16+ let mutable private lastMsgCount = 0
17+ let mutable private pageSize = 50
18+ let mutable private loadingMore = false
19+ let mutable private isAtBottom = true
20+
21+ let private scrollToEnd () =
22+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
23+ match scrollRef.TryValue with
24+ | Some sv -> sv.ScrollToEnd()
25+ | None -> ()
26+ , Avalonia.Threading.DispatcherPriority.Background) |> ignore
27+
28+ let private messageItem m =
29+ let align, cols, col =
30+ match m.Direction with
31+ | Outgoing _ -> HorizontalAlignment.Right, [ Dimension.Stars 3.; Dimension.Stars 7. ], 1
32+ | Incoming -> HorizontalAlignment.Left, [ Dimension.Stars 7.; Dimension.Stars 3. ], 0
33+
34+ let dashed =
35+ match m.Direction with
36+ | Outgoing DeliveryStatus.Sent -> true
37+ | _ -> false
38+
39+ let border =
40+ if dashed then
41+ Rectangle()
42+ .stroke(Avalonia.Media.Colors.Black)
43+ .strokeThickness(4.)
44+ .strokeDashArray([ 4.; 2. ])
45+ .isHitTestVisible(false)
46+ .onLoaded(fun args ->
47+ let rect = args.Source :?> Avalonia.Controls.Shapes.Rectangle
48+ let anim = Avalonia.Animation.Animation()
49+ anim.Duration <- System.TimeSpan.FromSeconds 3.
50+ anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
51+ anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
52+ let kf1 = Avalonia.Animation.KeyFrame()
53+ kf1.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.))
54+ kf1.Cue <- Avalonia.Animation.Cue(0.)
55+ let kf2 = Avalonia.Animation.KeyFrame()
56+ kf2.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.))
57+ kf2.Cue <- Avalonia.Animation.Cue(1.)
58+ anim.Children.Add(kf1)
59+ anim.Children.Add(kf2)
60+ anim.RunAsync(rect) |> ignore
61+ DismissError)
62+ else
63+ Rectangle().stroke(Avalonia.Media.Colors.Black).strokeThickness(4.).isHitTestVisible (false)
64+
65+ Grid(cols, [ Dimension.Auto ]) {
66+ Border(
67+ (Grid() {
68+ TextBlock(m.Body)
69+ .fontSize(14.)
70+ .fontFamily(Constants.fontFamily)
71+ .fontWeight(Avalonia.Media.FontWeight.Bold)
72+ .textWrapping(Avalonia.Media.TextWrapping.Wrap)
73+ .padding (8.)
74+
75+ border
76+ }))
77+ .horizontalAlignment(align)
78+ .margin(4.)
79+ .gridColumn (col)
80+ }
81+
15 let viewChat model pk compose =
82 let viewChat model pk compose =
16 let name = contactName model.Contacts pk
83 let name = contactName model.Contacts pk
17 let msgs = messagesFor pk model.Messages
84 let msgs = messagesFor pk model.Messages
@@ -20,8 +87,11 @@ module ViewChat =
20 (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
87 (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
21 (backButton (SetPage Conversations)).gridColumn (0)
88 (backButton (SetPage Conversations)).gridColumn (0)
22
89
23− (textField compose (fun text -> SetPage(Chat(pk, text))))
90+ TextBox(compose, fun text -> SetPage(Chat(pk, text)))
91+ .watermark("Message...")
92+ .fontFamily(Constants.fontFamily)
24 .verticalAlignment(VerticalAlignment.Stretch)
93 .verticalAlignment(VerticalAlignment.Stretch)
94+ .verticalContentAlignment(VerticalAlignment.Center)
25 .margin(8.)
95 .margin(8.)
26 .gridColumn (1)
96 .gridColumn (1)
27
97
@@ -35,39 +105,50 @@ module ViewChat =
35
105
36 (viewErrorBanner model.Error).gridRow (1)
106 (viewErrorBanner model.Error).gridRow (1)
37
107
108+ (let msgView =
38− (if msgs.IsEmpty then
109+ if msgs.IsEmpty then
110+ lastMsgCount <- 0
111+ pageSize <- 50
39− AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
112+ AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
40− else
113+ else
41− let layout = Avalonia.Layout.StackLayout()
114+ if msgs.Length <> lastMsgCount then
42− layout.DisableVirtualization <- true
115+ lastMsgCount <- msgs.Length
43−
116+ if isAtBottom then scrollToEnd ()
44− AnyView(
117+
45− ScrollViewer(
118+ let skip = max 0 (msgs.Length - pageSize)
46− ItemsRepeater(msgs, fun m ->
119+ let visible = msgs |> List.skip skip
47− let tick =
120+
48− match m.Direction with
121+ AnyView(
49− | Outgoing DeliveryStatus.Delivered -> " ✓"
122+ ScrollViewer(
50− | Outgoing DeliveryStatus.Sent -> ""
123+ VStack(0.) {
51− | Incoming -> ""
124+ for m in visible do
52−
125+ messageItem m
53− let align =
126+ })
54− match m.Direction with
127+ .reference(scrollRef)
55− | Incoming -> HorizontalAlignment.Right
128+ .onLoaded(fun _ ->
56− | Outgoing _ -> HorizontalAlignment.Left
129+ pageSize <- 50
57−
130+ loadingMore <- false
58− Border(
131+ isAtBottom <- true
59− TextBlock($"{m.Body}{tick}")
132+ scrollToEnd ()
60− .fontSize(14.)
133+ DismissError)
61− .padding (8.))
134+ .onScrollChanged(fun args ->
62− .borderThickness(Avalonia.Thickness(2.))
135+ let sv = args.Source :?> Avalonia.Controls.ScrollViewer
63− .borderBrush(Avalonia.Media.SolidColorBrush(Avalonia.Media.Colors.Black))
136+ isAtBottom <- sv.Offset.Y + sv.Viewport.Height >= sv.Extent.Height - 20.
64− .horizontalAlignment(align)
137+ if sv.Offset.Y < 50. && not loadingMore && skip > 0 then
65− .margin (4.))
138+ loadingMore <- true
66− .layout(layout)
139+ let oldHeight = sv.Extent.Height
67− )
140+ pageSize <- min msgs.Length (pageSize + 50)
68− .horizontalScrollBarVisibility(Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled)
141+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
69− .offset(Avalonia.Vector(0., System.Double.MaxValue))))
142+ match scrollRef.TryValue with
143+ | Some sv2 ->
144+ let newHeight = sv2.Extent.Height
145+ sv2.Offset <- Avalonia.Vector(0., sv2.Offset.Y + (newHeight - oldHeight))
146+ loadingMore <- false
147+ | None -> loadingMore <- false
148+ , Avalonia.Threading.DispatcherPriority.Background) |> ignore
149+ SetPage(Chat(pk, compose))))
150+
70− .gridRow (2)
151+ msgView).gridRow (2)
71 })
152 })
72
153
73 Styles.withBottomBar bar content
154 Styles.withBottomBar bar content