Pull to refresh and load more when scroll to bottom

This commit is contained in:
Gordon Pedersen 2024-06-18 10:02:03 +10:00
parent df05e8a819
commit 9841e05163
20 changed files with 394 additions and 24 deletions

View file

@ -3,7 +3,11 @@
public App(NavigatorService navigatorService) {
InitializeComponent();
#if WINDOWS
MainPage = new WindowsAppShell();
#else
MainPage = new AppShell();
#endif
NavigatorService = navigatorService;
}

View file

@ -2,13 +2,83 @@
@implements IDisposable
@inject NavigatorService NavigatorService
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
@inject State State
<NavMenu />
<main class="responsive max">
@Body
</main>
<script>
class Refresher {
constructor(targetEl) {
this.lastKnownScrollPosition = 0
this.ticking = false
this.atTop = true
this.atBottom = false
this.target = targetEl
this.target.addEventListener("scroll", this.scrollEvent)
}
scrolledTo = (scrollPos) => {
// Do something with the scroll position
if (this.atTop && scrollPos > 0) {
this.atTop = false
CSHARP.invokeMethodAsync("SetAtTop", false)
}
else if (!this.atTop && scrollPos == 0) {
// console.log("AT TOP")
this.atTop = true
CSHARP.invokeMethodAsync("SetAtTop", true)
}
const bottom = this.target.scrollHeight - Math.ceil(this.target.offsetHeight)
if (this.atBottom && scrollPos < bottom) {
this.atBottom = false
CSHARP.invokeMethodAsync("SetAtBottom", false)
}
else if (!this.atBottom && scrollPos >= bottom) {
// console.log("AT BOTTOM")
this.atBottom = true
CSHARP.invokeMethodAsync("SetAtBottom", true)
}
}
scrollEvent = (event) => {
this.lastKnownScrollPosition = Math.ceil(event.target.scrollTop)
if (!this.ticking) {
window.requestAnimationFrame(() => {
this.scrolledTo(this.lastKnownScrollPosition)
this.ticking = false
});
this.ticking = true
}
}
Dispose = () => {
this.target.removeEventListener("scroll", this.scrollEvent)
}
}
new Refresher(document.querySelector('main'))
</script>
@code {
private DotNetObjectReference<State>? DotNetRef { get; set; }
protected override void OnAfterRender(bool firstRender) {
base.OnAfterRender(firstRender);
if (firstRender) {
// See warning about memory above in the article
DotNetRef = DotNetObjectReference.Create(State);
JS.InvokeVoidAsync("injectCSharp", DotNetRef);
}
}
protected override void OnInitialized() {
base.OnInitialized();
NavigatorService.NavigationManager = NavigationManager;
@ -25,11 +95,12 @@
}
else if (!string.IsNullOrEmpty(State.SharePhoto)) {
NavigationManager.NavigateTo($"/sharepic/{State.SharePhoto}");
State.ShareString = null;
State.SharePhoto = null;
}
}
void IDisposable.Dispose() {
State.IntentReceived -= IntentRecieved;
DotNetRef?.Dispose();
}
}

View file

@ -3,6 +3,7 @@
@inject IJSRuntime JS
@inject NavigationManager Nav
<RefreshButton></RefreshButton>
<div class="row center-align">
<h3 class="page-heading"><i class="fa-solid fa-fw fa-at"></i>@Address</h3>
@ -51,7 +52,7 @@
}
</article>
</div>
<StatusList @ref="StatusList" StatusFunc="@(async() => await State.GetStatuses(Address))" Editable="@Editable"></StatusList>
<StatusList @ref="StatusList" StatusFunc="@(async(forceRefresh) => await State.GetStatuses(Address, forceRefresh))" Editable="@Editable"></StatusList>
@if(Address == State.SelectedAddressName) {
<button class="fab circle extra large-elevate" data-ui="#post-modal">
<i class="fa-solid fa-pen-to-square"></i>
@ -61,7 +62,7 @@
</div>
<div id="pics" class="page padding">
<PicList @ref="PicList" PicsFunc="@(async() => await State.GetPics(Address))" Editable="@Editable"></PicList>
<PicList @ref="PicList" PicsFunc="@(async(forceRefresh) => await State.GetPics(Address, forceRefresh))" Editable="@Editable"></PicList>
@if (Address == State.SelectedAddressName) {
<button class="fab circle extra large-elevate" data-ui="#post-modal">
<i class="fa-solid fa-camera-retro"></i>
@ -84,8 +85,8 @@
get => _address;
set {
_address = value;
if(StatusList != null) StatusList.StatusFunc = async () => await State.GetStatuses(_address);
if(PicList != null) PicList.PicsFunc = async () => await State.GetPics(_address);
if (StatusList != null) StatusList.StatusFunc = async (forceRefresh) => await State.GetStatuses(_address, forceRefresh);
if(PicList != null) PicList.PicsFunc = async (bool forceRefresh) => await State.GetPics(_address, forceRefresh);
}
}
public string ProfileUrl {

View file

@ -15,9 +15,12 @@
</AuthorizeView>
<div id="pics" class="responsive card-grid">
<PicList PicsFunc="@(async() => await State.GetPics())"></PicList>
<PicList PicsFunc="@(async(bool forceRefresh) => await State.GetPics(forceRefresh))"></PicList>
</div>
<RefreshButton></RefreshButton>
@code {
}

View file

@ -16,10 +16,7 @@
</AuthorizeView>
<div id="statuses" class="responsive">
<StatusList StatusFunc="@(async() => await State.GetStatuses())"></StatusList>
<StatusList StatusFunc="@(async(forceRefresh) => await State.GetStatuses(forceRefresh))"></StatusList>
</div>
@code {
}
<RefreshButton></RefreshButton>

View file

@ -1,6 +1,8 @@
@inject IJSRuntime JS
@inject State State
@implements IDisposable
@if (Editable) {
<EditPicDialog @ref="Dialog" id="EditPicModal"></EditPicDialog>
}
@ -13,19 +15,60 @@
@code {
[Parameter]
public Func<Task<List<Pic>?>> PicsFunc { get; set; }
public Func<bool, Task<List<Pic>?>> PicsFunc { get; set; }
[Parameter]
public bool Editable { get; set; } = false;
public EditPicDialog? Dialog { get; set; }
private List<Pic>? pics;
private int count { get; set; } = 0;
private int pageSize { get; } = 10;
// TODO: There is a noticable rendering delay between the pics loading and the page rendering
protected override async Task OnInitializedAsync() {
await base.OnInitializedAsync();
if (pics == null || pics.Count == 0) pics = await PicsFunc();
await InvokeAsync(StateHasChanged);
State.PropertyChanged += StateChanged;
State.CanRefresh = true;
if (pics == null || pics.Count == 0) await LoadNext();
await JS.InvokeVoidAsync("removeElementById", "pics-loading");
}
public async Task<bool> PullUp() {
int countBefore = count;
await LoadNext();
return (count != countBefore);
}
public async Task LoadNext(bool forceRefresh = false) {
if (pics == null) pics = new List<Pic>();
if (forceRefresh) {
count = 0;
pics.Clear();
}
var allPics = await PicsFunc(forceRefresh);
if(allPics != null && count < allPics.Count) {
pics.AddRange(allPics.Skip(count).Take(pageSize));
count = pics.Count;
}
await InvokeAsync(StateHasChanged);
}
private async void StateChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(State.IsRefreshing) && State.IsRefreshing) {
await LoadNext(true);
State.IsRefreshing = false;
}
if (e.PropertyName == nameof(State.AtBottom) && State.AtBottom) {
await LoadNext();
}
}
public void Dispose() {
State.PropertyChanged -= StateChanged;
State.CanRefresh = false;
}
}

View file

@ -0,0 +1,18 @@
@inject State State
<button id="refreshButton" class="absolute transparent circle top right margin" @onclick="() => State.IsRefreshing = true">
<i class="fa-solid fa-arrow-rotate-right @(State.IsRefreshing ? "fa-spin" : "")"></i>
</button>
@code {
protected override async Task OnInitializedAsync() {
await base.OnInitializedAsync();
State.PropertyChanged += StateChanged;
}
private async void StateChanged(object? sender, PropertyChangedEventArgs e) {
if(e.PropertyName == nameof(State.IsRefreshing)) {
await InvokeAsync(StateHasChanged);
}
}
}

View file

@ -1,6 +1,8 @@
@inject IJSRuntime JS
@inject State State
@implements IDisposable
@if (Editable) {
<EditStatusDialog @ref="Dialog" id="EditStatusModal"></EditStatusDialog>
}
@ -13,18 +15,60 @@
@code {
[Parameter]
public Func<Task<List<Status>?>> StatusFunc { get; set; }
public Func<bool,Task<List<Status>?>> StatusFunc { get; set; }
[Parameter]
public bool Editable { get; set; } = false;
public EditStatusDialog? Dialog { get; set; }
private List<Status>? statuses;
private int count { get; set; } = 0;
private int pageSize { get; } = 50;
protected override async Task OnInitializedAsync() {
await base.OnInitializedAsync();
if (statuses == null || statuses.Count == 0) statuses = await StatusFunc();
State.PropertyChanged += StateChanged;
State.CanRefresh = true;
if (statuses == null || statuses.Count == 0) await LoadNext();
await InvokeAsync(StateHasChanged);
await JS.InvokeVoidAsync("removeElementById", "statusLoading");
}
public async Task<bool> PullUp() {
int countBefore = count;
await LoadNext();
return (count != countBefore);
}
public async Task LoadNext(bool forceRefresh = false) {
if (statuses == null) statuses = new List<Status>();
if (forceRefresh) {
count = 0;
statuses.Clear();
}
var allStatuses = await StatusFunc(forceRefresh);
if (allStatuses != null && count < allStatuses.Count) {
statuses.AddRange(allStatuses.Skip(count).Take(pageSize));
count = statuses.Count;
}
await InvokeAsync(StateHasChanged);
}
private async void StateChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(State.IsRefreshing) && State.IsRefreshing) {
await LoadNext(true);
State.IsRefreshing = false;
}
if(e.PropertyName == nameof(State.AtBottom) && State.AtBottom) {
await LoadNext();
}
}
public void Dispose() {
State.PropertyChanged -= StateChanged;
State.CanRefresh = false;
}
}

View file

@ -1,4 +1,5 @@
@using System.Net.Http
@using System.ComponentModel
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@ -11,3 +12,4 @@
@using Neighbourhood.omg.lol.Components
@using Neighbourhood.omg.lol.Models
@using Markdig
@using BcdLib.Components

View file

@ -2,13 +2,17 @@
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Neighbourhood.omg.lol"
xmlns:models="clr-namespace:Neighbourhood.omg.lol.Models"
x:DataType="models:State"
x:Class="Neighbourhood.omg.lol.MainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html" UrlLoading="BlazorUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Components.Routes}" />
</BlazorWebView.RootComponents>
<RefreshView x:Name="refreshView" IsRefreshing="{Binding IsRefreshing}" IsEnabled="False">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html" UrlLoading="BlazorUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Components.Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</RefreshView>
</ContentPage>

View file

@ -1,13 +1,26 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebView;
using Microsoft.AspNetCore.Components.WebView.Maui;
using Neighbourhood.omg.lol.Models;
using System.ComponentModel;
using System.Diagnostics;
namespace Neighbourhood.omg.lol {
public partial class MainPage : ContentPage {
private State State { get; set; }
public MainPage() {
InitializeComponent();
State = IPlatformApplication.Current!.Services.GetService<State>()!;
BindingContext = State;
State.PropertyChanged += State_PropertyChanged;
}
private void State_PropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName == nameof(State.CanRefresh) || e.PropertyName == nameof(State.AtTop)) {
refreshView.IsEnabled = State.CanRefresh && State.AtTop;
}
}
private void BlazorUrlLoading(object? sender, UrlLoadingEventArgs e) {

View file

@ -1,4 +1,5 @@
using Markdig;
using BcdLib.Components;
using Markdig;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@ -15,6 +16,7 @@ namespace Neighbourhood.omg.lol {
});
builder.Services.AddMauiBlazorWebView();
builder.Services.AddBcdLibPullComponent();
builder.Services.AddTransient<LoginWebViewPage>();
builder.Services.AddTransient<EphemeralWebPage>();

View file

@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.ComponentModel;
using System.Text.Json;
namespace Neighbourhood.omg.lol.Models {
public class State {
public class State : INotifyPropertyChanged {
// Main data lists
public List<Status>? Statuses { get; set; }
public List<Pic>? Pics { get; set; }
@ -57,7 +59,47 @@ namespace Neighbourhood.omg.lol.Models {
}
}
// refreshing
public event PropertyChangedEventHandler? PropertyChanged;
private bool _isRefreshing;
public bool IsRefreshing {
get => _isRefreshing;
set {
_isRefreshing = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRefreshing)));
}
}
private bool _canRefresh;
public bool CanRefresh {
get => _canRefresh;
set {
_canRefresh = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanRefresh)));
}
}
private bool _atTop;
public bool AtTop {
get => _atTop;
set {
if (_atTop != value) {
_atTop = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AtTop)));
}
}
}
private bool _atBottom;
public bool AtBottom {
get => _atBottom;
set {
if (_atBottom != value) {
_atBottom = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AtBottom)));
}
}
}
// api service
private RestService api { get; set; }
@ -160,5 +202,18 @@ namespace Neighbourhood.omg.lol.Models {
public async Task RefreshPics() => await GetPics(forceRefresh: true);
public async Task RefreshNow() => await GetNowGarden(forceRefresh: true);
[JSInvokable("SetAtTop")]
public void SetAtTop(bool atTop) {
if(AtTop != atTop) AtTop = atTop;
//if(CanRefresh != atTop) CanRefresh = atTop;
}
[JSInvokable("SetAtBottom")]
public void SetAtBottom(bool atBottom) {
if(AtBottom != atBottom) AtBottom = atBottom;
}
}
}

View file

@ -196,6 +196,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BcdLib.PullComponent" Version="0.4.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.6" />
@ -219,6 +220,12 @@
<MauiXaml Update="LoginWebViewPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="WindowsAppShell.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="WindowsMainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
</ItemGroup>
</Project>

14
WindowsAppShell.xaml Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="Neighbourhood.omg.lol.WindowsAppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Neighbourhood.omg.lol"
Shell.FlyoutBehavior="Disabled"
Title="omg.lol Neighbourhood">
<ShellContent
ContentTemplate="{DataTemplate local:WindowsMainPage}"
Route="MainPage" />
</Shell>

10
WindowsAppShell.xaml.cs Normal file
View file

@ -0,0 +1,10 @@
namespace Neighbourhood.omg.lol;
public partial class WindowsAppShell : Shell {
public WindowsAppShell() {
InitializeComponent();
Routing.RegisterRoute(nameof(LoginWebViewPage), typeof(LoginWebViewPage));
Routing.RegisterRoute(nameof(EphemeralWebPage), typeof(EphemeralWebPage));
}
}

12
WindowsMainPage.xaml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Neighbourhood.omg.lol"
x:Class="Neighbourhood.omg.lol.WindowsMainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html" UrlLoading="BlazorUrlLoading">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Components.Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>

24
WindowsMainPage.xaml.cs Normal file
View file

@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebView;
using Microsoft.AspNetCore.Components.WebView.Maui;
using Neighbourhood.omg.lol.Models;
using System.ComponentModel;
using System.Diagnostics;
namespace Neighbourhood.omg.lol {
public partial class WindowsMainPage : ContentPage {
private State State { get; set; }
public WindowsMainPage() {
InitializeComponent();
}
private void BlazorUrlLoading(object? sender, UrlLoadingEventArgs e) {
if (e.Url.Host == "home.omg.lol" && e.Url.AbsolutePath == "/oauth/authorize") {
e.UrlLoadingStrategy = UrlLoadingStrategy.CancelLoad;
Shell.Current.GoToAsync(nameof(LoginWebViewPage));
}
}
}
}

View file

@ -395,4 +395,45 @@ a.row.indent {
:is(button,.button,.chip).large.small > .responsive {
inline-size: 3rem;
}
}
[class|="fa"].animated {
animation: rotating 1s ease-in-out infinite;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.pull-container {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.pull-container > .pull-wrapper
{
position: relative;
}
.pull-tip {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
height: var(--pull-refresh-head-height, 50px);
width: 100%;
}
.pull-down-tip {
position: absolute;
transform: translateY(-100%);
}
.pull-up-tip {
}

View file

@ -1,5 +1,6 @@
window.injectCSharp = async function (helper) {
window.CSHARP = helper
console.info("Recieved DotNetReference", window.CSHARP)
}
@ -11,4 +12,8 @@ async function delay(t) {
async function removeElementById(id) {
document.getElementById(id)?.remove()
}
function Dispose(obj) {
if (obj && typeof obj.Dispose === "function") obj.Dispose()
}