已关闭
fix: 补充close/open链卸载守卫与兜底;panresponderThreshold阈值生效;supportedOrientations朝向约束 #32
fix: 补充close/open链卸载守卫与兜底;panresponderThreshold阈值生效;supportedOrientations朝向约束 #32
已关闭
mazheng创建于 8月31日关闭于 22 天前
mazheng
8月31日

修改点

本 PR 仅改动 src/modal.tsx 一个文件。

1. 关闭/打开时序的 TypeError 防护

  • 新增实例字段 isUnmounted,在 componentWillUnmount() 中置为 true;同时把 contentRef / backdropRef 置空,让卸载后仍触发的回调成为 no-op。
  • close() 的 setAnimation 回调开头增加守卫:contentRef 为空或已卸载时,直接走 finalizeClose() 而不去解引用 contentRef.startAnimation。
  • open() 链路上的 requestAnimationFrame 回调、setAnimation 回调、startAnimation 完成回调、以及 backdrop 之后的第二个 rAF 回调,各自增加 isUnmounted / contentRef 守卫。

2. 打开/关闭生命周期兜底,避免 isTransitioning 卡死

  • 抽出 finalizeClose():把原先内联在 startAnimation 完成回调里的 finalizeClose 闭包提为类成员方法,供正常路径与降级路径复用。
  • 新增 completeOpenWithoutAnimation():动画宿主不可用时,跳过内容动画直接完成打开流程(清理 interactionHandle、复位 isTransitioning、setState 后回调 onModalShow)。
  • open() 中 contentRef 为空、close() 中 contentRef 为空时,分别走上述降级路径;close() 的降级路径补发 onModalWillHide。

3. panResponderThreshold 真正生效

  • onStartShouldSetPanResponder 中移除 onSwipeStart 的触发(触点按下时位移为 0,此时并未开始滑动)。
  • onPanResponderMove 中,累计位移 |dx| / |dy| 均小于 panResponderThreshold 时直接 return:不移动模态、不发 onSwipeStart / onSwipeMove;跨过阈值后再触发 onSwipeStart。

4. supportedOrientations 约束(HarmonyOS 模拟)

  • 新增 constrainToSupportedOrientation(width, height):仅在 Platform.OS === 'harmony' 时生效;当前方向不被 supportedOrientations 支持时交换宽高。
  • 新增 constrainDimensionsToSupportedOrientation(),在 open() 开头调用;handleDimensionsUpdate() 也接入同一约束。

5. 滑动百分比改用真实窗口尺寸

  • 新增 getGestureDeviceHeight() / getGestureDeviceWidth():取 props.deviceHeight/Width 或 Dimensions.get('window'),不经过方向约束。
  • calcDistancePercentage() 的 down / right 分支改用这两个方法;布局仍沿用受方向约束的 getDeviceWidth() / getDeviceHeight()。

6. hideModalContentWhileAnimating 语义修正

  • shouldHideContent 由 !showContent || (isTransitioning && !animationReady) 改为 !showContent || (isTransitioning && (props.hideModalContentWhileAnimating || !animationReady))。

7. CodeCheck 圈复杂度整改(15b332e)

  • 把 open() 内层层嵌套的 requestAnimationFrame / setAnimation / startAnimation 回调提取为类成员方法:resetBackdropAnimation / resetContentAnimation / startOpenAnimation / handleOpenAnimationPrepared / handleOpenAnimationEnd / revealContent;open() 只保留同步流程并改用提前 return 守卫;close() 复用 resetContentAnimation 消除重复。
  • open() 圈复杂度 24 → 6,close() 18 → 16。守卫条件、执行顺序、_pendingRAF 入队时机均保持不变。

根因分析

① 关闭模态时 cannot read property 'startAnimation' of null

close() 的原实现:

this.contentRef.setAnimation(animationOut, () => {
  ...
  this.contentRef.startAnimation(...)   // ← 这里
});

react-native-animatable 的 setAnimation 内部通过 setState 提交 animationStyle,回调在该 setState 完成后才执行。在 RNOH 上,原生 Modal 宿主可能在这个窗口期内被平台 dismiss,React 随即把 contentRef 置为 null;回调此时再解引用 this.contentRef.startAnimation 就抛 TypeError。

open() 链路上的 setAnimation / startAnimation / requestAnimationFrame 回调存在同样的时间窗口。而且这些回调还会 setState,组件卸载后执行会产生 setState-after-unmount 告警。

② isTransitioning 卡死导致模态彻底失效

open() / close() 都以 if (this.isTransitioning) return; 作为入口守卫,并在进入时置 true,只在动画完成回调里复位为 false。

原实现中,contentRef 为空时:

  • open() 的 if (this.contentRef) {...} 整块被跳过,没有 else 分支——isTransitioning 永远停在 true。
  • close() 同理。

一旦落入这个状态,此后所有 open() / close() 调用都会被入口守卫挡掉,模态彻底不可用,且 onModalShow / onModalHide 再也不会触发。这比 ① 的 TypeError 影响更持久——TypeError 至少还能在日志里看见。

③ panResponderThreshold 声明了但从未参与判断

defaultProps 里有 panResponderThreshold: 4,propTypes 里也声明了,但代码中没有任何一处读取它。实际行为是:只要 onStartShouldSetPanResponder 返回 true,onSwipeStart 立刻触发,随后任意微小位移都会移动模态——阈值形同虚设,轻点内容区就可能把模态拖动一下。

而且 onSwipeStart 被放在 onStartShouldSetPanResponder(触点按下)里触发,语义也不对:按下时 dx/dy 都是 0,滑动尚未开始。

④ supportedOrientations 在 HarmonyOS 上无任何约束

该 prop 在 iOS 上由原生 Modal 呈现层强制执行,在 Android 上是文档明确的 no-op,而 HarmonyOS 侧既没有原生支持也没有 JS 模拟,等于完全忽略。

⑤ 滑动百分比分母可能为负(本 PR 内的二次修正,0c883f0)

constrainToSupportedOrientation() 在当前方向不被支持时会交换宽高,交换后的值经 constrainDimensionsToSupportedOrientation() / handleDimensionsUpdate() 写入 state.deviceWidth / deviceHeight。

而 calcDistancePercentage() 用的 gestureState.y0 / moveY(及 x0 / moveX)是真实屏幕坐标。拿真实坐标去除以交换后的尺寸会得到错误的百分比;当触点超过交换后的边界时分母还会变负,导致 newOpacityFactor > 1、遮罩透明度异常,onSwipeMove 也会收到超出 [0,1] 的百分比。

举例:400×800 竖屏 + supportedOrientations: ['landscape'] + swipeDirection: 'down',从 y0 = 600 下滑,(moveY - 600) / (400 - 600) 得 -0.5,factor 为 1.5。

⑥ hideModalContentWhileAnimating 被 ohos 闪屏修复覆盖

为修复 RNOH 打开时的一帧闪屏,shouldHideContent 引入了 !animationReady 条件。但这条件把 hideModalContentWhileAnimating 的原语义(为 true 时整个过渡期间都隐藏内容)挤掉了:animationReady 一旦为 true,即使 hideModalContentWhileAnimating 为 true,内容也会显示出来。


方案说明(AI输出)

卸载守卫:采用「实例标志 + 置空 ref」双重防护。isUnmounted 覆盖 React 已卸载的情况,置空 contentRef / backdropRef 覆盖宿主被平台 dismiss 但组件尚未卸载的情况——后者正是 RNOH 上的典型场景,单靠 isUnmounted 挡不住。

降级路径:所有守卫命中时不是简单 return,而是走 completeOpenWithoutAnimation() / finalizeClose()。这一点很关键——直接 return 会把 isTransitioning 留在 true,正好落进根因 ② 的死锁。降级路径保证 isTransitioning 一定复位、interactionHandle 一定释放、onModalShow / onModalHide 一定触发,代价只是丢掉这一次动画。

finalizeClose 由内联闭包提为类成员方法,是为了让正常路径与降级路径跑完全相同的收尾逻辑,避免两套实现漂移。

阈值判断放在 onPanResponderMove:onStartShouldSetPanResponder 阶段没有位移信息,无法做阈值判断。放在 move 里用累计 dx/dy 判断,同时把 onSwipeStart 一并挪过来,使「阈值」与「滑动开始」两个概念在时间点上对齐。阈值未跨过时整体 return,保证模态不动、回调不发,轻点内容区不会误触发滑动。

supportedOrientations:限定 Platform.OS === 'harmony' 才生效。iOS 由原生强制执行、Android 是文档化的 no-op,JS 模拟若无平台判断会改变这两个平台的既有行为。

滑动尺寸与布局尺寸分离:不回退方向约束,而是引入一组只用于手势计算的尺寸方法。布局需要受约束的几何(这正是 supportedOrientations 模拟的目的),手势数学需要真实窗口空间(因为 gestureState 就是真实坐标)——两者本就是不同的量,分开取值比在某一处做补偿更直接,也不改变 supportedOrientations 的模拟行为。

圈复杂度整改:严格按"提取方法"做纯结构调整,不动任何守卫条件、执行顺序和 _pendingRAF 入队时机,便于 review 逐段比对。


测试情况

本仓库未引入自动化单测框架(无 __tests__ / *.test.*),验证以 example 工程 + 真机手动执行为主。

新增测试用例(按需)

  • 不涉及(本仓库无自动化单测工程)

触发的已有测试用例(按需)

  • 本工程 br_rnoh0.72 分支 example/src/ → Modal 示例页 → ✅ 通过
  • RNT 工程 → 待补充(如未触发请填"不涉及")

手动验证

  • 已验证修复有效:
    • 反复快速开关模态、以及在关闭动画过程中卸载页面,不再出现 cannot read property 'startAnimation' of null。
    • 动画宿主不可用的降级场景下,isTransitioning 正确复位,后续 open() / close() 仍可用,onModalShow / onModalHide 正常触发。
    • panResponderThreshold 生效:小于阈值的轻微拖动不移动模态、不触发 onSwipeStart / onSwipeMove;跨过阈值后正常进入滑动。
    • supportedOrientations 约束下打开模态,呈现方向符合声明。
    • 400×800 竖屏 + supportedOrientations: ['landscape'] + swipeDirection: 'down' 场景下滑,onSwipeMove 百分比落在 [0,1],遮罩透明度正常(此前为 1.5)。
    • hideModalContentWhileAnimating 为 true 时整个过渡期间内容保持隐藏;为 false 时 ohos 闪屏防护仍生效。
  • 已确认不影响其他功能:avoidKeyboard、customBackdrop、onBackdropPress、onBackButtonPress 等行为无变化;15b332e 为纯结构调整。
  • CI:编译 ✅ / 静态检查(CodeCheck)✅ / DCO ✅(首次提交未签名,已在 f9d2a68 修正)

资料修改

  • 是否已在README说明该接口/功能的变化:否。panResponderThreshold 与 supportedOrientations 在 HarmonyOS 上由"未实现"变为"已实现(JS 模拟)",README 的属性支持表建议同步更新,本 PR 暂未改动。
  • 是否已在README和package.json修改了版本信息:否。本 PR 只改动了 src/modal.tsx,package.json 仍为 13.0.3-rc.1,未新增 CHANGELOG 条目(本仓库当前无 CHANGELOG.md)。建议合入前补上版本号与变更记录。

依赖关系变更

  • 无新增依赖,无产物变更(本库为纯 JS 实现,不含 harmony 原生模块与 har)。
  • 改动范围仅 src/modal.tsx 一个文件。
  • 兼容性:无 API 签名变更、无 breaking change。以下为向上游/文档语义对齐的行为修正,依赖旧行为的业务代码需留意:
    • onSwipeStart 触发时机由「触点按下」推迟到「累计位移跨过 panResponderThreshold」。
    • 小于阈值的拖动不再移动模态。
    • hideModalContentWhileAnimating 为 true 时,过渡期间内容保持隐藏。

Checklist

likedislike
当前Pull Request已关闭, 关闭人@mazheng
openharmony_ci
openharmony_ci成员
8月31日 评论:

感谢提交 Pull Requests !此PR未通过DCO校验。
校验失败可能原因:

1. 未签署“DCO协议”(开发者原创声明协议),在线签署、查看签署状态。

2. Commits 中未包含 Signed-off-by信息,参考FAQ处理。

修复上述问题后,在PR的评论框输入“check dco” ,单击”评论”,系统将再次进行DCO校验。

当前检测到如下Commits 未包含Signed-off-by信息:


Thanks for submitting a pull request. This pull request has not passed the DCO check.
Possible causes:

1. You have not signed the Developer Certificate of Origin (DCO). Sign the DCO and check DCO status.

2. The commits do not contain the Signed-off-by information. To resolve this issue, see FAQs.

After resolving the preceding issues, enter check dco in the comment box of this pull request and click Comment. The system will check DCO status again.

The following commits do not contain the Signed-off-by information:

likedislike
openharmony_ciopenharmony_ci成员
8月31日 添加了label:dco检查失败
cpf-manager
cpf-manager8月31日进行代码检视1
src/modal.tsx
@@ -657,0 +753,4 @@
753+ // Content host unavailable (e.g. the Modal host dismissed the content
754+ // or the component is unmounting). Complete the open sequence without
755+ // animation so the modal does not stay stuck in isTransitioning state.
756+ this.completeOpenWithoutAnimation();
cpf-manager
cpf-manager8月31日评论:

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】open() 降级路径跳过 onModalWillShow 回调

● 问题: open() 的 else 分支(line 752-757,contentRef 为 null 时的降级路径)直接调用 this.completeOpenWithoutAnimation()(line 756),未先调用 this.props.onModalWillShow。而同一函数的 if (this.contentRef) 分支在 line 740 明确调用了 this.props.onModalWillShow && this.props.onModalWillShow()。completeOpenWithoutAnimation(line 700-714)在 isVisible 为 true 时调用的是 this.props.onModalShow()(line 712,"已展示"回调),而非 onModalWillShow("将展示"回调)。对比同文件中 close() 函数的 else 分支(line 800-805),在调用 this.finalizeClose() 前于 line 804 调用了 this.props.onModalWillHide && this.props.onModalWillHide(),两个回调路径是对称的——close() 降级路径调用 onModalWillHide,open() 降级路径却未调用 onModalWillShow。

● 影响: 当 contentRef 为 null 但组件尚未 unmount 时(如 PR 注释 line 753-755 所述"the Modal host dismissed the content"场景,RNOH Modal host 在原生层移除内容视图导致 ref 回调被以 null 调用,或组件在 unmount 过程中 ref 被提前置 null 但 isUnmounted 尚未生效的时序窗口),onModalShow 会通过 completeOpenWithoutAnimation 触发,而 onModalWillShow 被跳过。依赖 onModalWillShow → onModalShow 回调时序契约的用户代码(如在 onModalWillShow 中初始化展示前状态、埋点、动画前置准备)将丢失前置回调,而"已展示"回调仍会触发,造成回调时序契约破坏。

● 建议: 在 else 分支的 this.completeOpenWithoutAnimation() 之前增加 this.props.onModalWillShow && this.props.onModalWillShow();,与 close() 的 else 分支(line 804)保持对称。修改后 else 分支应为:

} else {
  this.props.onModalWillShow && this.props.onModalWillShow();
  this.completeOpenWithoutAnimation();
}
likedislike
Mmazheng
9月1日 强制推送  1 个提交:ecc15310-fix: 补充close/open链卸载守卫与兜底;panresponderThreshold阈值生效;supportedOrientations朝向约束
openharmony_ci
openharmony_ci成员
9月1日 评论:

感谢提交 Pull Requests!
Thanks for submitting a pull request.

likedislike
此处折叠了92条消息 查看更多
Mmazheng
22 天前 推送  1 个提交:693ad3d6-fix: componentDidUpdate切换backdropOpacity前先停止进行中的遮罩动画;修正supportedOrientations约束的注释描述
openharmony_ci
openharmony_ci成员
22 天前 评论:

感谢提交 Pull Requests!
Thanks for submitting a pull request.

likedislike
cpf-manager
cpf-manager22 天前进行代码检视1
src/modal.tsx
@@ -478,2 +549,2 @@
478- opacity: this.props.backdropOpacity,
479- });
549+ // Reset backdrop opacity with a smooth animation
550+ Animated.timing(this.backdropAnimated, {
cpf-manager
cpf-manager22 天前评论:

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】onPanResponderRelease 启动 Animated.timing 前未停止 in-flight 动画

● 问题:src/modal.tsx:548-555 的 onPanResponderRelease 中,启动 Animated.timing(this.backdropAnimated, ...) 前未调用 this.backdropAnimated.stopAnimation()。而 open()(line 809)、close()(line 857-859)、componentDidUpdate(line 349)在启动相同 backdropAnimated 的 Animated.timing 前都显式调用了 stopAnimation(),此处遗漏了相同处理。

● 影响:当 modal 处于 open/close 的 backdrop 淡入淡出动画期间(默认 backdropTransitionInTiming/OutTiming = 300ms),用户点击内容区域并释放(未达到 swipeThreshold 的轻触或未完成滑动的释放),onPanResponderRelease 会启动第二个 Animated.timing 实例。两个 Animated.timing 同时运行在同一个 Animated.Value 上,每帧都调用 setValue,造成 backdrop 透明度抖动。尤其是 close 动画期间(目标值 0)与 release 重置动画(目标值 backdropOpacity=0.7)目标值相反,冲突更明显,backdrop 透明度会在 0 与 0.7 之间反复跳动。

触发路径:open()/close() 启动 backdrop Animated.timing(300ms)→ 300ms 内用户点击内容区域 → onStartShouldSetPanResponder 返回 true(line 449)→ 用户释放 → onPanResponderRelease 启动第二个 Animated.timing(未 stopAnimation)→ 两个动画并发在同一 Animated.Value 上 → backdrop 透明度抖动。

● 建议:在 if (this.backdropRef) 块内、Animated.timing 之前添加 this.backdropAnimated.stopAnimation(),与 open()/close()/componentDidUpdate 保持一致:

if (this.backdropRef) {
  this.backdropAnimated.stopAnimation();
  Animated.timing(this.backdropAnimated, {
    toValue: this.props.backdropOpacity,
    duration: this.props.backdropTransitionInTiming,
    useNativeDriver: this.props.useNativeDriverForBackdrop === true,
  }).start();
}
likedislike
cpf-manager
cpf-manager22 天前进行代码检视1
src/modal.tsx
@@ -423,0 +483,4 @@
483+ // it directly sets the Animated.Value without scheduling an
484+ // animation (transitionTo would start a new spring per move event
485+ // and lag behind the finger).
486+ this.backdropAnimated.setValue(
cpf-manager
cpf-manager22 天前评论:

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】onPanResponderMove 的 setValue 会被运行中的 Animated.timing 覆盖

● 问题:src/modal.tsx:486-488 的 onPanResponderMove 中调用 this.backdropAnimated.setValue(...) 更新滑动时的 backdrop 透明度,但未先停止 open()/close() 启动的 in-flight Animated.timing。Animated.Value.setValue() 不会停止运行中的动画:open()(line 811-815)启动的 Animated.timing 会在后续每一帧继续调用 setValue,覆盖 onPanResponderMove 设置的滑动值。

● 影响:当 modal 刚 open(backdrop 淡入动画运行中,默认 300ms)时用户立即开始滑动,backdrop 透明度会在滑动值(onPanResponderMove setValue)和淡入动画值(Animated.timing 每帧 setValue)之间反复跳动,导致 backdrop 不跟随手指平滑变化,出现抖动/回跳。同样在 close 动画期间滑动也会出现此问题。PR 注释提到 "setValue() is synchronous and works regardless of useNativeDriver setting",但未考虑到运行中的 Animated.timing 也会每帧调用 setValue 覆盖手动设置的值。

触发路径:open() 启动 backdrop 淡入 Animated.timing(300ms)→ 300ms 内用户滑动超过 panResponderThreshold → onPanResponderMove 进入 isSwipeDirectionAllowed 分支调用 setValue(swipeValue) → 同一帧/下一帧 Animated.timing 也调用 setValue(openAnimValue) → backdrop 透明度在两值之间抖动,不跟随手指。

● 建议:在滑动开始时(currentSwipingDirection 从 null 变为非 null 的分支内,line 472 赋值之前)停止 in-flight 动画:

if (!this.currentSwipingDirection) {
  this.backdropAnimated.stopAnimation();
}
this.currentSwipingDirection = this.getSwipingDirection(gestureState);
likedislike
Mmazheng
22 天前 关闭了 pull request