面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
打磨 iOS Web Clip 体验(一):原生质感的下拉刷新

打磨 iOS Web Clip 体验(一):原生质感的下拉刷新

2026年5月23日2,1557分钟
✦AI 生成摘要

把网站添加到 iOS 主屏幕后,Safari 的原生下拉刷新消失了。这篇文章记录如何用 Touch Events 和 SVG 圆弧进度从零实现一个行为和视觉都接近系统原生的下拉刷新组件。


目录
  • 1. 问题背景
  • 2. 只在 standalone 模式下激活
  • 3. Touch 事件的三段式
  • 3.1 起始点
  • 3.2 拖拽过程
  • 3.3 释放
  • 4. SVG 圆弧进度指示器
  • 5. 位置动画
  • 6. 事件监听的注册与清理
  • 7. 整体结构
  • 8. 后续改进:视觉稳定性
  • 8.1 导航栏按钮水合抖动
  • 8.2 骨架屏快速闪烁
目录
  • 1. 问题背景
  • 2. 只在 standalone 模式下激活
  • 3. Touch 事件的三段式
  • 3.1 起始点
  • 3.2 拖拽过程
  • 3.3 释放
  • 4. SVG 圆弧进度指示器
  • 5. 位置动画
  • 6. 事件监听的注册与清理
  • 7. 整体结构
  • 8. 后续改进:视觉稳定性
  • 8.1 导航栏按钮水合抖动
  • 8.2 骨架屏快速闪烁
目录
  1. 1. 问题背景
  2. 2. 只在 standalone 模式下激活
  3. 3. Touch 事件的三段式
  4. 3.1 起始点
  5. 3.2 拖拽过程
  6. 3.3 释放
  7. 4. SVG 圆弧进度指示器
  8. 5. 位置动画
  9. 6. 事件监听的注册与清理
  10. 7. 整体结构
  11. 8. 后续改进:视觉稳定性
  12. 8.1 导航栏按钮水合抖动
  13. 8.2 骨架屏快速闪烁
PWA前端
相关文章
  • 01
    打磨 iOS Web Clip 体验(三):一次 Service Worker 卡死排查2026/06
  • 02
    打磨 iOS Web Clip 体验(二):接入 Web Push 通知2026/06
  • 03
    Prefetch 的完整图景2026/07
← 上一篇「相关文章」功能的设计与实现
下一篇 →「批注」功能的设计与演进

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

本站近期在持续打磨 iOS Web Clip 场景下的体验。这篇是这个系列的第一篇,解决的是「站内交互的连续性」——下拉刷新。第二篇解决「站外触达的可能性」——Web Push 通知。


1. 问题背景

把网站通过「添加到主屏幕」变成 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()。


2. 只在 standalone 模式下激活

第一个判断:组件只应该在 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 场景,故不处理。)


3. Touch 事件的三段式

下拉刷新的交互分三个阶段,分别对应三个 touch 事件:

touchstart  → 记录起始 Y 坐标
touchmove   → 计算拖拽距离,更新指示器位置
touchend    → 判断是否超过阈值,触发刷新或回弹

3.1 起始点

src/components/ui/PullToRefresh.tsx
function onTouchStart(e: TouchEvent) {
  if (window.scrollY !== 0) return;
  startY.current = e.touches[0].clientY;
  pulling.current = true;
}

window.scrollY !== 0 这个前置检查很关键。如果用户已经向下滚动了页面,继续向下的触摸是在滚动内容,不是在触发下拉刷新。只有在页面顶部(scrollY === 0)才开始记录起始点。

3.2 拖拽过程

src/components/ui/PullToRefresh.tsx
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) 给拖拽距离加上上限,指示器不会无限跟手,有一种「到顶了」的阻尼感。

3.3 释放

src/components/ui/PullToRefresh.tsx
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 归零,指示器缩回去。


4. SVG 圆弧进度指示器

指示器是一个带圆弧进度的圆形图标,分两个状态:拖拽中(弧长跟随进度增长)和刷新中(旋转 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°。配合内部圆弧从短到长,形成「拧紧」的视觉感——松手时用户会感受到一种「已就位」的确定感,而不只是一条弧线变长。


5. 位置动画

指示器平时藏在顶部上方,随拖拽滑入:

<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 生效,指示器平滑弹到固定位置。


6. 事件监听的注册与清理

src/components/ui/PullToRefresh.tsx
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 的清理函数移除所有监听器,防止组件卸载后残留。


7. 整体结构

src/components/ui/PullToRefresh.tsx
"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 层级在导航栏之上:

src/app/layout.tsx
+ import { PullToRefresh } from "@/components/ui/PullToRefresh";
 
  <ThemeProvider>
+   <PullToRefresh />
    <Nav />
    <main>{children}</main>
  </ThemeProvider>

8. 后续改进:视觉稳定性

Web Clip 全屏模式下没有浏览器 UI 遮挡,界面的任何抖动都直接暴露在用户视野里,比普通 Safari 标签页更难被忽略。这节记录后来发现并修复的两处闪动问题。

8.1 导航栏按钮水合抖动

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 则完全从文档流中移除。正是这个差异让布局在水合前后保持稳定。)

8.2 骨架屏快速闪烁

文章页的 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 在用户不主动打开站点的情况下把新内容送到锁屏。

1.5
));
}
(
"touchend"
, onTouchEnd);
};
setRefreshing
]
=
useState
(
false
);
const startY = useRef(0);
const pulling = useRef(false);
useEffect(() => {
if (!("standalone" in navigator) ||
!(navigator as Navigator & { standalone: boolean }).standalone) return;
// ... 事件处理函数 + 注册/清理
}, [pullY]); // pullY 在依赖数组里会导致每次拖拽都重新注册事件监听器
if (pullY === 0 && !refreshing) return null; // 未激活时不渲染任何 DOM
// ... SVG 指示器
}