把网站添加到 iOS 主屏幕后,Safari 的原生下拉刷新消失了。这篇文章记录如何用 Touch Events 和 SVG 圆弧进度从零实现一个行为和视觉都接近系统原生的下拉刷新组件。
本站近期在持续打磨 iOS Web Clip 场景下的体验。这篇是这个系列的第一篇,解决的是「站内交互的连续性」——下拉刷新。第二篇解决「站外触达的可能性」——Web Push 通知。
把网站通过「添加到主屏幕」变成 Web Clip(Chen 注:Web Clip 是 Apple 的术语,指通过 Safari「添加到主屏幕」创建的网站快捷方式。它以全屏模式打开,没有地址栏和工具栏,外观与原生 App 相近。Web Clip 本质上就是 iOS 上的 PWA。)(或 PWA standalone 模式(Chen 注:PWA standalone 是 Web App Manifest 的 display 模式之一,声明后浏览器以独立窗口打开页面,隐藏地址栏等浏览器 UI。iOS 上通过 `navigator.standalone === true` 可以检测当前是否处于这个模式。))之后,Safari 的 WebView 会去掉地址栏和工具栏,呈现一个接近原生 App 的全屏体验。但随之而来有一个问题:浏览器原生的下拉刷新手势也同时消失了。
在普通 Safari 标签页里,用户可以在页面顶部继续下拉触发刷新。进入 standalone 模式后,这个手势被系统屏蔽,页面不再响应。对于内容会更新的站点,这意味着用户必须找到「重新载入」的入口——而在没有工具栏的情况下,这个入口根本不存在。
解决方案是自己实现一个下拉刷新组件,监听 touch 事件,用 SVG 绘制进度指示器,在拖拽距离超过阈值后触发 location.reload()。
第一个判断:组件只应该在 Web Clip / PWA 模式下工作,普通浏览器标签页里浏览器自己有刷新机制,不需要干预。
if (
!("standalone" in navigator) ||
!(navigator as Navigator & { standalone: boolean }).standalone
) return;navigator.standalone 是 iOS Safari 的私有属性,在 standalone 模式下为 true,普通标签页为 false 或不存在。Android Chrome 的 PWA 用 window.matchMedia('(display-mode: standalone)') 判断,但本站的目标场景是 iOS Web Clip,用前者足够。(Chen 注:Android Chrome 在 PWA 模式下本身已有系统级下拉刷新,若直接叠加自定义实现,需先用 CSS `overscroll-behavior-y: contain` 屏蔽原生手势,否则两者会同时触发。本文仅针对 iOS Web Clip 场景,故不处理。)
下拉刷新的交互分三个阶段,分别对应三个 touch 事件:
touchstart → 记录起始 Y 坐标
touchmove → 计算拖拽距离,更新指示器位置
touchend → 判断是否超过阈值,触发刷新或回弹function onTouchStart(e: TouchEvent) {
if (window.scrollY !== 0) return;
startY.current = e.touches[0].clientY;
pulling.current = true;
}window.scrollY !== 0 这个前置检查很关键。如果用户已经向下滚动了页面,继续向下的触摸是在滚动内容,不是在触发下拉刷新。只有在页面顶部(scrollY === 0)才开始记录起始点。
function onTouchMove(e: TouchEvent) {
if (!pulling.current) return;
const dy = e.touches[0].clientY - startY.current;
if (dy <= 0) { setPullY(0); return; }
e.preventDefault();
setPullY(Math.min(dy, INDICATOR_MAX *
两个细节:
e.preventDefault() 阻止浏览器的默认滚动行为。必须在 dy > 0(确认是向下拖)之后才调用,否则会误拦截正常的向上滚动。这也是为什么这个监听器必须注册为 passive: false——只有非 passive 的监听器才能调用 preventDefault()。Math.min(dy, INDICATOR_MAX * 1.5) 给拖拽距离加上上限,指示器不会无限跟手,有一种「到顶了」的阻尼感。function onTouchEnd() {
if (!pulling.current) return;
pulling.current = false;
if (pullY >= THRESHOLD) {
setRefreshing(true);
setTimeout(() => window.location.reload(), 300);
} else {
setPullY(0);
}
}超过阈值(80px)就触发刷新,延迟 300ms 是为了让指示器播放完旋转动画再跳转。没超过就把 pullY 归零,指示器缩回去。
指示器是一个带圆弧进度的圆形图标,分两个状态:拖拽中(弧长跟随进度增长)和刷新中(旋转 spinner)。两个状态共用同一套 SVG 结构。
核心是 strokeDasharray 和 strokeDashoffset 的配合:
const r = 8;
const circ = 2 * Math.PI * r; // 圆的周长,约 50.3px
// 拖拽中:弧长 = 进度 × 周长
<circle strokeDasharray={circ} strokeDashoffset={circ * (1 - progress)} />
// 刷新中:固定显示 3/4 弧,持续旋转
<circle strokeDasharray={circ} strokeDashoffset={circ * 0.25} />strokeDasharray 把描边变成虚线,总长度设为整圆周长。strokeDashoffset 控制从哪里开始画——偏移为 0 时画完整圆,偏移为周长时什么都不画,偏移为 circ * (1 - progress) 时画出对应进度的弧。
默认起点在 3 点钟方向,用 transform: rotate(-90deg) 把起点转到 12 点钟,视觉上更符合「从顶部开始」的直觉。
拖拽中还额外给整个 SVG 加了一个旋转,让圆弧方向跟随拖拽:
style={{ transform: `rotate(${progress * 180}deg)` }}progress 从 0 到 1,SVG 整体转 0° 到 180°。配合内部圆弧从短到长,形成「拧紧」的视觉感——松手时用户会感受到一种「已就位」的确定感,而不只是一条弧线变长。
指示器平时藏在顶部上方,随拖拽滑入:
<div style={{
position: "fixed",
top: 0,
transform: `translateY(${Math.min(pullY, INDICATOR_MAX) - INDICATOR_MAX}px)`,
transition: refreshing ? "transform 0.2s ease" : "none",
}}>translateY 的初始值是 -INDICATOR_MAX(完全藏在顶部边界外),随着 pullY 增加逐渐滑入。transition 只在 refreshing 状态才开启,避免拖拽跟手时出现延迟。触发刷新后,transition 生效,指示器平滑弹到固定位置。
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchmove", onTouchMove, { passive: false });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener
touchmove 必须是 passive: false,原因上面提到了。touchstart 和 touchend 不需要 preventDefault(),加 passive: true 让浏览器知道可以并行处理,性能更好。
useEffect 的清理函数移除所有监听器,防止组件卸载后残留。
"use client";
import { useEffect, useRef, useState } from "react";
const THRESHOLD = 80; // 触发刷新的最小拖拽距离(px)
const INDICATOR_MAX = 56; // 指示器最大可见高度(px)
export function PullToRefresh() {
const [pullY, setPullY] = useState(0);
const [refreshing,
pullY(Chen 注:`pullY` 作为依赖是为了让 `onTouchEnd` 闭包始终读到最新值。更干净的写法是用 `useRef` 存 `pullY` 的镜像,这样 `useEffect` 只需注册一次,避免每次 `pullY` 变化都重新绑定事件监听器。) 和 refreshing 用 state 驱动渲染,startY 和 pulling 用 ref 存储——前者需要触发重渲染,后者只是计算中间值,不需要。
if (pullY === 0 && !refreshing) return null 让组件在静止状态下完全不占 DOM,不影响其他元素的事件处理。
在 layout.tsx 里注册到全局,放在 <Nav /> 之前,确保 z-index 层级在导航栏之上:
+ import { PullToRefresh } from "@/components/ui/PullToRefresh";
<ThemeProvider>
+ <PullToRefresh />
<Nav />
<main>{children}</main>
</ThemeProvider>Web Clip 全屏模式下没有浏览器 UI 遮挡,界面的任何抖动都直接暴露在用户视野里,比普通 Safari 标签页更难被忽略。这节记录后来发现并修复的两处闪动问题。
Nav 组件通过 next-themes 读取当前主题,而主题信息存储在客户端的 localStorage,SSR 阶段不可用。原实现用 {mounted && <ThemeButton />} 避免服务端/客户端不匹配,但副作用是主题按钮在水合完成前完全缺席,水合后突然出现,导致导航栏右侧「弹入」一个按钮——这在 Web Clip 无浏览器 UI 的全屏环境里格外明显。(Chen 注:next-themes 的主题值存在 localStorage,SSR 阶段无法读取,只有客户端水合后才能拿到 resolvedTheme。原先的 {mounted && <ThemeButton />} 写法会让按钮在水合完成前完全不存在于 DOM,水合后突然弹入,触发导航栏右侧的布局偏移。)
修复方案是将条件渲染改为 visibility: hidden 包裹:按钮始终占据位置,水合前不可见,水合后切换为 visible,布局尺寸全程不变,偏移消失。移动端的推送订阅按钮(PushIconButton)也做了同样处理。(Chen 注:visibility: hidden 与 display: none 的关键区别:hidden 仍然占据布局空间,只是不可见;none 则完全从文档流中移除。正是这个差异让布局在水合前后保持稳定。)
loading.tsx 骨架屏在快速网络下会产生反效果:服务端响应很快时,骨架只在 1–2 帧内可见,造成比「没有任何过渡」更突兀的闪烁。这在 Web Clip 模式的软导航(View Transition)流程里尤其明显,因为每次站内跳转都会经过这个 Suspense 边界。(Chen 注:Next.js App Router 的 loading.tsx 是 React Suspense 的 fallback,在服务端数据流式传输完成前渲染。如果响应够快,fallback 只在极短时间内可见,反而造成比「没有骨架」更差的闪烁感——因为用户看到的是一帧空白骨架突然弹出又立刻消失。)
修复方案是给骨架容器加 animation-fill-mode: both 配合 120ms 入场延迟:加载在 120ms 内完成时,骨架的 opacity 还没从 0 离开,真实内容已经接管,骨架对用户不可见;超过 120ms 才加载完成时,骨架平滑淡入,体验符合预期。(Chen 注:animation-fill-mode: both 配合 animation-delay 的组合:both 让动画在延迟期间也应用 from 关键帧(opacity: 0),而不是等延迟结束才从初始值开始。这样骨架在 120ms 内始终是 opacity: 0,快速加载时用户完全感知不到它的存在。)
@keyframes skeleton-enter {
from { opacity: 0; }
to { opacity: 1; }
}
.note-loading {
opacity: 0;
animation: skeleton-enter 0.15s ease 0.12s forwards;
}下拉刷新让 Web Clip 在站内的体验更完整,但用户离开站点之后就完全断联了。下一篇《打磨 iOS Web Clip 体验(二):接入 Web Push 通知》记录如何用 Web Push 在用户不主动打开站点的情况下把新内容送到锁屏。