Skip to content

Conversation

@kubaflo
Copy link
Contributor

@kubaflo kubaflo commented Dec 6, 2025

PR: Fix #32244 - RefreshView Indicator Not Displaying on Programmatic Refresh (iOS)

Fixes #32244

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Summary

Fixed an issue on iOS where the RefreshView refresh indicator would not display when IsRefreshing was set to true programmatically. The indicator would only appear when the user manually pulled to refresh. This PR ensures the refresh indicator displays correctly in both scenarios.

Quick verification:

  • ✅ Tested on iOS - Issue resolved
  • ✅ Indicator now appears for programmatic refresh
  • ✅ Manual pull-to-refresh still works correctly
📋 Click to expand full PR details

Root Cause

When IsRefreshing is set to true programmatically on iOS, the code was calling UIRefreshControl.BeginRefreshing() synchronously in the same runloop iteration as the scroll view offset change.

According to Apple's UIKit documentation and the iOS community, BeginRefreshing() does not properly animate or display the refresh indicator when:

  1. Called synchronously after modifying UIScrollView.ContentOffset
  2. The refresh wasn't triggered by user interaction

The original implementation:

if (_isRefreshing)
{
	TryOffsetRefresh(this, IsRefreshing);
	_refreshControl.BeginRefreshing();
}
else
{
	_refreshControl.EndRefreshing();
	TryOffsetRefresh(this, IsRefreshing);
}

The issue didn't occur with manual pull-to-refresh because iOS handles the scroll offset and refresh control activation internally through the gesture recognizer, which properly sequences the animation.


Solution

Dispatch the BeginRefreshing() call to the main queue asynchronously. This ensures UIKit has time to process and render the scroll view contentOffset change before beginning the refresh animation.

Files Changed:

  • src/Core/src/Platform/iOS/MauiRefreshView.cs - Lines 43-50

Implementation:

DispatchQueue.MainQueue.DispatchAsync(async () =>
{
	if (_isRefreshing)
	{
		TryOffsetRefresh(this, IsRefreshing);
		_refreshControl.BeginRefreshing();
	}
	else
	{
		_refreshControl.EndRefreshing();
		TryOffsetRefresh(this, IsRefreshing);
	}
});

Why this works:

  • The async dispatch moves BeginRefreshing() to the next runloop iteration
  • UIKit has time to layout the scroll view with the new offset
  • The refresh indicator animation displays properly
  • This is the standard iOS pattern for programmatic refresh (documented in Apple forums and Stack Overflow)

Testing

Before fix:

  • Setting IsRefreshing = true programmatically → Indicator does NOT appear ❌
  • Manual pull to refresh → Indicator appears ✅

After fix:

  • Setting IsRefreshing = true programmatically → Indicator appears ✅
  • Manual pull to refresh → Indicator appears ✅

User workaround validation:
The reported workaround of adding a 100ms delay before setting IsRefreshing = true worked for the same reason - it gave UIKit time to process the layout before calling BeginRefreshing(). This PR eliminates the need for that workaround.


Edge Cases Tested

  • Rapid toggling - Setting IsRefreshing true→false→true quickly doesn't cause crashes or visual glitches
  • Already refreshing - Setting IsRefreshing = true when already refreshing (via manual pull) works correctly
  • Immediate cancel - Setting IsRefreshing = false immediately after setting it to true works without issues
  • Multiple RefreshViews - Multiple RefreshViews on same page work independently

Platforms Tested:

  • ✅ iOS 18.5 (Simulator)
  • ✅ Expected to work on all iOS versions (uses standard UIKit patterns)

Breaking Changes

None. This is a bug fix that makes programmatic refresh work as expected without changing any public APIs.


Related Issues

Copilot AI review requested due to automatic review settings December 6, 2025 23:27
@kubaflo kubaflo self-assigned this Dec 6, 2025
@dotnet-policy-service dotnet-policy-service bot added the community ✨ Community Contribution label Dec 6, 2025
@dotnet-policy-service
Copy link
Contributor

Hey there @@kubaflo! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses issue #32244 where the RefreshView refresh indicator would not display on iOS when IsRefreshing is set to true programmatically. The fix uses DispatchQueue.MainQueue.DispatchAsync to defer the BeginRefreshing()/EndRefreshing() calls to the next runloop iteration, giving UIKit time to properly layout the scroll view before initiating the refresh animation.

Key Changes:

  • Wrapped the refresh control state changes in an asynchronous dispatch to the main queue
  • Added explanatory comment about the iOS UIKit timing requirement
  • Maintains the same logical flow for both starting and stopping refresh

Comment on lines 48 to 60
DispatchQueue.MainQueue.DispatchAsync(async () =>
{
TryOffsetRefresh(this, IsRefreshing);
_refreshControl.BeginRefreshing();
}
else
{
_refreshControl.EndRefreshing();
TryOffsetRefresh(this, IsRefreshing);
}
if (_isRefreshing)
{
TryOffsetRefresh(this, IsRefreshing);
_refreshControl.BeginRefreshing();
}
else
{
_refreshControl.EndRefreshing();
TryOffsetRefresh(this, IsRefreshing);
}
});
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

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

The dispatched code block references _isRefreshing which can change between when the dispatch is queued and when it executes, creating a race condition. The value should be captured locally before dispatching.

The pattern should be:

var isRefreshing = _isRefreshing;
DispatchQueue.MainQueue.DispatchAsync(() =>
{
    if (isRefreshing)
    {
        TryOffsetRefresh(this, isRefreshing);
        _refreshControl.BeginRefreshing();
    }
    else
    {
        _refreshControl.EndRefreshing();
        TryOffsetRefresh(this, isRefreshing);
    }
});

This ensures the dispatched operation uses the value of _isRefreshing from when the property setter was called, preventing inconsistent behavior if IsRefreshing is set multiple times in quick succession.

Copilot uses AI. Check for mistakes.
if (_isRefreshing != _refreshControl.Refreshing)
{
if (_isRefreshing)
// iOS 26+ quirk: without dispatching to the main queue, the UIRefreshControl
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

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

The comment mentions "iOS 26+" which is incorrect - iOS version numbers have never reached 26. The current latest version is iOS 18. This should be corrected to reference the actual iOS versions where this issue occurs, or simply state "iOS" without a specific version number if it affects all versions.

Suggested change
// iOS 26+ quirk: without dispatching to the main queue, the UIRefreshControl
// iOS quirk: without dispatching to the main queue, the UIRefreshControl

Copilot uses AI. Check for mistakes.
// indicator may not appear. Scheduling BeginRefreshing/EndRefreshing on the
// next runloop tick ensures the control has been inserted and laid out
// before we toggle its refreshing state.
DispatchQueue.MainQueue.DispatchAsync(async () =>
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

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

The lambda expression is marked as async but doesn't contain any await statements. This creates an unnecessary async state machine. Since DispatchAsync doesn't return a Task and the lambda body is synchronous, remove the async keyword.

Change:

DispatchQueue.MainQueue.DispatchAsync(async () =>

To:

DispatchQueue.MainQueue.DispatchAsync(() =>
Suggested change
DispatchQueue.MainQueue.DispatchAsync(async () =>
DispatchQueue.MainQueue.DispatchAsync(() =>

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RefreshView refresh indicator not displaying when refreshing is done programatically

1 participant