UIDex

Pull to Refresh

السحب للتحديث

SwipeRefreshLayout / .refreshable

also calledrefresh control، swipe to refresh، pull down to refresh، drag to refresh، SwipeRefreshLayout، UIRefreshControl، بول تو ريفريش، السحب لأسفل للتحديث، اسحب للتحديث، التحديث بالسحب، إيماءة التحديث، سحب لإعادة التحميل

Pull to refresh is a gesture that re-fetches the data already on screen: with the list resting at its very top, the user drags it down, an indicator emerges from under the top bar, and releasing past a set distance starts the request. What separates it from every other control is that it has no resting form (no button, no label), so nobody finds it except by trying it or by watching someone else, which is why most people cannot name it. It is not infinite scroll, which fires at the end of the list and appends older items, while this fires at the start and prepends the newest. It is not a progress indicator either, because the spinning circle is the result of the gesture rather than the gesture itself, and the same circle can appear from a Refresh menu item with no drag at all. And it is not a swipe action: that gesture runs horizontally on a single row and uncovers buttons that wait to be tapped, while this one runs vertically on the whole list and uncovers nothing to press. The rule that settles it in review: the gesture begins at scroll offset zero, or it is something else. And because it is invisible, it can never be the only way to refresh a screen.

If you called it…

"drag the list down and new stuff shows up""the spinner that appears when you tug the top of the screen""swipe down at the top and let go to reload""the way news apps get you the latest posts""there is no refresh button, you just pull the page"

Live specimen

Interact with the demo. Every part is real and numbered.

الإشعارات
أفلت للتحديث
  • تم شحن طلبكقبل ٤ د
  • فاتورة يوليو جاهزةقبل ٢٠ د
  • تمّت الموافقة على الإجازةقبل ساعة
  • تقرير الأسبوعأمس
  • تحديث سياسة الاسترجاعأمس
  • رسالة من فريق الدعمالأحد
آخر تحديث ٩:٤١
RTLاسحب القائمة لأسفل. الإيماءة رأسية فلا تنعكس، والدوران يبقى مع عقارب الساعة

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a pull to refresh gesture over a scrollable list. Arm the gesture only while the scroll offset is exactly zero, and ignore the drag anywhere else so it never fights ordinary scrolling. Damp the movement so the list travels about half the distance of the finger, set the trigger threshold between 60 and 80 dp, and put a rubber band stop a little past it. Show an explicit armed state before the finger lifts: the arrow completes, the label changes, and a light haptic fires. On release, park the indicator at a fixed offset, keep the old rows visible, and never swap the list for a skeleton. Tie the end of the spin to the request actually finishing rather than to a timer, and cancel an in-flight request when a new pull arrives. On success, return the list to zero, preserve the reading position even though items were inserted at the top, then speak the result through an aria-live="polite" region or an android:accessibilityLiveRegion="polite" view, and say "Nothing new" when nothing changed. On failure, show an explicit message and never return silently. Always ship a visible alternative path to refresh (a button in the top bar or a menu item), because the gesture is invisible and keyboard users cannot reach it. The motion is vertical, so do not multiply the delta by a direction sign and do not mirror the indicator rotation in RTL, but use logical properties for everything you draw around it: padding-inline, margin-inline, inset-inline-start, text-align: start, and never left or right. Isolate the timestamp with <bdi>. Set overscroll-behavior-block: contain on the scroller so the overscroll never chains to the document and wakes the native browser refresh, and switch touch-action from pan-y to none for the life of the drag, because pan-y leaves the vertical pan to the browser and your pointer events stop arriving.

How it behaves right-to-left

This section is ours alone.

The gesture does not mirror

never mirrors

The whole gesture lives on the block axis: the finger goes down, the list follows, and page direction touches neither. Do not multiply the delta by a direction sign and do not reach for var(--dir) the way you would on an inline-axis control, because clientY on its own is enough and transform: translateY(...) is correct in both directions. Teams that apply a blanket "every gesture flips" rule to this element land on an indicator that travels upward in the Arabic build, or on a negative threshold that never resolves.

The indicator rotation

never mirrors

The indicator turns clockwise in Arabic exactly as it does in English, because rotation direction is a clock convention and carries no linguistic meaning to flip. A blanket [dir="rtl"] svg { transform: scaleX(-1) } turns it counter-clockwise and mirrors the pull arrow along with it, so scope the flip to a class you put on horizontal arrows by hand; transform has no logical form, so the browser will never make that call for you. In UIKit, UIRefreshControl draws its own spinner and takes no image, and if you substitute a custom one, do not call imageFlippedForRightToLeftLayoutDirection() on it: that call is an explicit request to mirror, and a vertical arrow has nothing to reverse.

Indicator text and the timestamp

mirrors

Any text that rides with the indicator ("Pull to refresh", "Last updated 9:41") follows page direction, so align it with text-align: start and not left. If you lay the circle and the text out in one row, use gap and padding-inline instead of margin-left, because the visual order of the row flips while DOM order stays put. The timestamp is its own hazard: dropping 12:04 PM or a Latin time-zone name into an Arabic sentence lets the bidi algorithm reorder the pieces on screen, so isolate the run with <bdi> or unicode-bidi: isolate and PM will stop jumping to the head of the line.

A horizontal swipe on the same list

mirrors

Layer horizontal swipe actions onto the same rows and you are on the inline axis, which mirrors completely: what a leftward drag uncovered in English is uncovered by a rightward drag in Arabic. In SwiftUI write .swipeActions(edge: .trailing), and on Android pass ItemTouchHelper.START and ItemTouchHelper.END to SimpleCallback rather than LEFT and RIGHT, because the first pair resolves against the layout direction and the second is physical. The dX value inside onChildDraw stays physical regardless (negative for leftward motion whatever the layout direction), so read the sign from layoutDirection before you decide which button to paint behind the row.

Insets around the indicator

mirrors

The gesture is vertical, but the chrome around it is not, and the SwipeRefreshLayout circle sits centred so nothing in it mirrors on its own. The moment you offset it toward one side or park a label beside it, use padding-inline-start and margin-inline-end on the web and NSDirectionalEdgeInsets in UIKit. UIScrollView.contentInset is a UIEdgeInsets with physical left and right fields and no logical counterpart on that type, so what you set there stays where it was when the app flips and you have to swap it by hand.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

SwiftUI.refreshable { await reload() }Put it on a List and the system owns the whole gesture; the spin ends when the async closure returns.
UIKitscrollView.refreshControl = UIRefreshControl()The older API, where endRefreshing() is yours to call; forget it and the circle spins forever.
AndroidXSwipeRefreshLayout.setOnRefreshListenerWraps exactly one scrollable child, and setRefreshing(false) is entirely manual.
Jetpack ComposePullToRefreshBox(isRefreshing, onRefresh)The current Material 3 API; underneath it sit Modifier.pullToRefresh and PullToRefreshState for a custom indicator.
CSSoverscroll-behavior-block: containKeeps the overscroll from chaining up to the document, which is what arms the native browser refresh.
ARIAaria-live="polite" + aria-busy="true"The web has no native gesture here, so a spoken announcement is all a screen reader ever gets.
DOMelement.setPointerCapture(pointerId)Keeps pointer events flowing to the container after the finger leaves its bounds mid-drag.

See also

Updated · 2026-08-19