2025-05-23 23:06:17
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/192
转眼间,这份工作又快到一年了。说起来,我毕业以来,经历了两次换工作,第一次是因为工作内容和压力的问题,第二次是因为裁员。不幸的是,这两次实际都没有满一年。
又是这里节骨眼上,恰好是一年,又开始有点焦虑了。焦虑的是,做的事是否达到预期,是否有成长,产品时候有前途等等。一年的时间说长不长说短也不短。
回顾过去的一年,我自认为还是能达到预期的吧。从我刚入职,从一个毛坯的产品开始做,重新设计组件 UI,重构底层,赋予一些有意思的交互设计以及功能,到 Web 端开放公测。不过只是三个月的时间。又是一个月完成了 Web 对 Mobile 的响应式改造。现如今 Mobile App 也已经上线很久。而这些在一年的时间中全部完成了。对于一个只有 4 个开发成员的项目来说,感觉已经很不错了。
希望越来越好吧。
回看今年,各路神仙打架。AI 发展越来越快。该说是好事还是坏事呢。如今写代码变得越来越不动脑子了,只是无脑敲着 Tab,出了问题也不知道。非常害怕这样依赖 AI 之后连很简单的逻辑都不会写了。AI 在进步而我在退步。
前段日子,也是借助 AI 完成了大部分的代码。虽然 AI 现在对 Swift 还是不太熟悉。但是循序渐进的引导最终还是能达到一个预期的结果。现在非常流行 Vibe Coding,即便是完全不会编程,只需要一个好点子,以及良好的文字表达,就能引导 AI 一步步做出你想要的结果。慢慢的代码越来越不值钱,最值钱的是好的点子。像我这种只会切图的低级程序员真的那一天就突然被淘汰了。现在的 AI 可比我会写多了。这种焦虑感越来越强。
确实会有一种很矛盾的感觉,一方面对自己所能开发的领域、边界有了更多信心,可以开发前端、iOS 甚至是各种之前并不了解的技术栈;而另一方面,对于自己离开 AI 后独立写代码的信心在显著下降,连带着自己独立思考的能力。 -- 周报 #95 - All AI 与 No AI
2025-05-11 21:28:23
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/191
记录最近随手拍的烂片。
2025-05-11 01:57:06
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/190
有时间整理了下 iCloud 相册,删除了许多没有留恋价值的图片。回顾老照片,感受到当时的感动和喜悦,现在看来也是一种珍贵的回忆。
这段回忆从大学开始,再之前的也找不到了。
2018 年,大一。
小米笔记本 + 黑果,踏上开发之路。
2019 年,大二。开始学习前端,刚开始,学的是 Vue2 + Epxress。
2019 年底,找到了一个 Remote 的实习机会。学习 React。然后在这条路上越走越远。
2020 年,经过了半年疫情和实习,在下半年返校季,凭自己的努力换上了 Macbook Pro,黑果转正。也买了人生第一台游戏机,Nintendo Switch。
2020 年的十月,原神上线了。而我在沉迷塞尔达传说旷野之息。
2020 年底,我开始写 Mix Space,一写就是 5 年。
我把这个小窝,布置的很好,这也是我最快乐的快乐的一段时间。
现在想起来前司给我过生日还是挺感动的。2021.4.1
我真的很早就有在写 Swift UI 了,虽然那时候和 Lakr 还没有和见过面。于 2021 年 4 月在学校图书馆。
2021 年劳动节,第一次来杭州,见到了 Lakr。准备前往蚂蚁实习的预备。
2021.7.7,第一次租房,在杭州,三墩,单间,2750。现在回看真是被割惨了。
2021.8 底,离开了,这两个月过得非常煎熬,一点都不快乐。
后面就是秋招了。
在后面我就毕业了。
大学时光匆匆。
2025-05-09 01:01:19
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/build-simple-navigation-with-react-native-screens
上回说到,我们已经大概了解了 React Native Screen 内部是如何工作的。这篇文章将综合前面的内容,实现一个简单的 React Native Navigation。
一个 Navigation 最基础的就是要实现一个 navigate 方法。
navigate 方法需要实现前进后退的基本行为。
模拟在 iOS Native 中,我们可以使用 pushController 和 presentController 两个方法去实现前进的行为。
那么我们可以命名为:
pushControllerView
推入 Stack NavigationpresentControllerView
推入 Modal然后是后退的行为,命名为:
back
回退 Stack Navigationdismiss
关闭 Modal然后我们需要集中式管理 push 和 present 的 navigation 的数据,然后在 UI 中呈现和反馈。
顺着上面的构想,我们首先需要一个 Navigation 的类。
class Navigation {}
我们需要把 navigation 的数据保存在这个类中,所以我还需要定义数据的类型。
export interface Route {
id: string
Component?: NavigationControllerView<any>
element?: React.ReactElement
type: NavigationControllerViewType
props?: unknown
screenOptions?: NavigationControllerViewExtraProps
}
export type NavigationControllerViewExtraProps = {
/**
* Unique identifier for the view.
*/
id?: string
/**
* Title for the view.
*/
title?: string
/**
* Whether the view is transparent.
*/
transparent?: boolean
} & Pick<
ScreenProps,
| 'sheetAllowedDetents'
| 'sheetCornerRadius'
| 'sheetExpandsWhenScrolledToEdge'
| 'sheetElevation'
| 'sheetGrabberVisible'
| 'sheetInitialDetentIndex'
| 'sheetLargestUndimmedDetentIndex'
>
export type NavigationControllerView<P = {}> = FC<P> &
NavigationControllerViewExtraProps
上面我们定义 NavigationControllerView 的类型,和 Route 的类型。NavigationControllerView 用于定义 NavigationView 的组件类型,Route 用于在 Navigation 类中保存 navigation 的数据。
为了实现在 UI 中的响应式,我们使用 Jotai 去管理这个数据。
export type ChainNavigationContextType = {
routesAtom: PrimitiveAtom<Route[]>
}
在 Navigation 类中初始化数据:
export class Navigation {
private ctxValue: ChainNavigationContextType
constructor(ctxValue: ChainNavigationContextType) {
this.ctxValue = ctxValue
}
static readonly rootNavigation: Navigation = new Navigation({
routesAtom: atom<Route[]>([]),
})
}
上面已经定义了 Navigation 的类型,然后我们通过对数据的控制来实现 push/back 的操作。
class Navigation {
private viewIdCounter = 0
private __push(route: Route) {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const hasRoute = routes.some((r) => r.id === route.id)
if (hasRoute && routes.at(-1)?.id === route.id) {
console.warn(`Top of stack is already ${route.id}`)
return
} else if (hasRoute) {
route.id = `${route.id}-${this.viewIdCounter++}`
}
jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])
}
private resolveScreenOptions<T>(
view: NavigationControllerView<T>,
): Required<NavigationControllerViewExtraProps> {
return {
transparent: view.transparent ?? false,
id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,
title: view.title ?? '',
// Form Sheet
sheetAllowedDetents: view.sheetAllowedDetents ?? 'fitToContents',
sheetCornerRadius: view.sheetCornerRadius ?? 16,
sheetExpandsWhenScrolledToEdge:
view.sheetExpandsWhenScrolledToEdge ?? true,
sheetElevation: view.sheetElevation ?? 24,
sheetGrabberVisible: view.sheetGrabberVisible ?? true,
sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,
sheetLargestUndimmedDetentIndex:
view.sheetLargestUndimmedDetentIndex ?? 'medium',
}
}
pushControllerView<T>(view: NavigationControllerView<T>, props?: T) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type: 'push',
Component: view,
props,
screenOptions,
})
}
presentControllerView<T>(
view: NavigationControllerView<T>,
props?: T,
type: Exclude<NavigationControllerViewType, 'push'> = 'modal',
) {
const screenOptions = this.resolveScreenOptions(view)
this.__push({
id: screenOptions.id,
type,
Component: view,
props,
screenOptions,
})
}
}
之后,back 的操作也非常简单。
class Navigation {
private __pop() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastRoute = routes.at(-1)
if (!lastRoute) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))
}
/**
* Dismiss the current modal.
*/
dismiss() {
const routes = jotaiStore.get(this.ctxValue.routesAtom)
const lastModalIndex = routes.findLastIndex((r) => r.type !== 'push')
if (lastModalIndex === -1) {
return
}
jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
}
back() {
return this.__pop()
}
}
从上面的代码不难看出,其实我们只是通过对数据的操作实现 Navigation 的逻辑。而真正要在 UI 中呈现 Navigation 的效果还是需要通过 React Native Screens 来实现。
在上面的文章中,我们已经知道了我们只需要通过传入不同 React Children 到 React Native Screens 的 <ScreenStack />
中就能实现原生的 navigate 的效果。
那我们现在只需要透过 Navigation 类中管理的数据,通过一些转换就能实现了。
首先我们在 React 中定义一个 Navigation 上下文对象,确保得到正确的 Navigation 实例(如有多个)。
export const NavigationInstanceContext = createContext<Navigation>(null!)
然后,编写一个 RootStackNavigation 组件。
import { SafeAreaProvider } from 'react-native-safe-area-context'
import type { ScreenStackHeaderConfigProps } from 'react-native-screens'
import { ScreenStack } from 'react-native-screens'
interface RootStackNavigationProps {
children: React.ReactNode
headerConfig?: ScreenStackHeaderConfigProps
}
export const RootStackNavigation = ({
children,
headerConfig,
}: RootStackNavigationProps) => {
return (
<SafeAreaProvider>
<NavigationInstanceContext value={Navigation.rootNavigation}>
<ScreenStack style={StyleSheet.absoluteFill}>
<ScreenStackItem headerConfig={headerConfig} screenId="root">
{children}
</ScreenStackItem>
</ScreenStack>
</NavigationInstanceContext>
</SafeAreaProvider>
)
}
在 App 的入口文件中,我们使用 RootStackNavigation 组件包裹整个应用。
export default function App() {
return (
<RootStackNavigation>
<HomeScreen>
</RootStackNavigation>
)
}
const HomeScreen = () => {
return (
<View>
<Text>Home</Text>
</View>
)
}
RootStackNavigation 组件的 Children 为首屏,也是 Navigation 的根组件,不参与整体的 navigate 行为,即不能被 pop。
接下来我们需要把这些数据转换到 React 元素传入到 React Native Screens 的 <ScreenStackItem />
中。
const ScreenItemsMapper = () => {
const chainCtxValue = use(ChainNavigationContext)
const routes = useAtomValue(chainCtxValue.routesAtom)
const routeGroups = useMemo(() => {
const groups: Route[][] = []
let currentGroup: Route[] = []
routes.forEach((route, index) => {
// Start a new group if this is the first route or if it's a modal (non-push)
if (index === 0 || route.type !== 'push') {
// Save the previous group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
// Start a new group with this route
currentGroup = [route]
} else {
// Add to the current group if it's a push route
currentGroup.push(route)
}
})
// Add the last group if it's not empty
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
return groups
}, [routes])
return (
<GroupedNavigationRouteContext value={routeGroups}>
{routeGroups.map((group) => {
const isPushGroup = group.at(0)?.type === 'push'
if (!isPushGroup) {
return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />
}
return <MapScreenStackItems key={group.at(0)?.id} routes={group} />
})}
</GroupedNavigationRouteContext>
)
}
const MapScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
return routes.map((route) => {
return (
<ScreenStackItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</ScreenStackItem>
)
})
})
const ModalScreenStackItems: FC<{
routes: Route[]
}> = memo(({ routes }) => {
const rootModalRoute = routes.at(0)
const modalScreenOptionsCtxValue = useMemo<
PrimitiveAtom<ScreenOptionsContextType>
>(() => atom({}), [])
const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)
if (!rootModalRoute) {
return null
}
const isFormSheet = rootModalRoute.type === 'formSheet'
const isStackModal = !isFormSheet
// Modal screens are always full screen on Android
const isFullScreen =
isAndroid ||
(rootModalRoute.type !== 'modal' && rootModalRoute.type !== 'formSheet')
if (isStackModal) {
return (
<ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>
<WrappedScreenItem
stackPresentation={rootModalRoute?.type}
key={rootModalRoute.id}
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
{...modalScreenOptions}
>
<ModalSafeAreaInsetsContext hasTopInset={isFullScreen}>
<ScreenStack style={StyleSheet.absoluteFill}>
<WrappedScreenItem
screenId={rootModalRoute.id}
screenOptions={rootModalRoute.screenOptions}
>
<ResolveView
comp={rootModalRoute.Component}
element={rootModalRoute.element}
props={rootModalRoute.props}
/>
</WrappedScreenItem>
{routes.slice(1).map((route) => {
return (
<WrappedScreenItem
stackPresentation={'push'}
key={route.id}
screenId={route.id}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</WrappedScreenItem>
)
})}
</ScreenStack>
</ModalSafeAreaInsetsContext>
</WrappedScreenItem>
</ModalScreenItemOptionsContext>
)
}
return routes.map((route) => {
return (
<ModalScreenItemOptionsContext
value={modalScreenOptionsCtxValue}
key={route.id}
>
<ModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>
<WrappedScreenItem
screenId={route.id}
stackPresentation={route.type}
screenOptions={route.screenOptions}
>
<ResolveView
comp={route.Component}
element={route.element}
props={route.props}
/>
</WrappedScreenItem>
</ModalSafeAreaInsetsContext>
</ModalScreenItemOptionsContext>
)
})
})
const ResolveView: FC<{
comp?: NavigationControllerView<any>
element?: React.ReactElement
props?: unknown
}> = ({ comp: Component, element, props }) => {
if (Component && typeof Component === 'function') {
return <Component {...(props as any)} />
}
if (element) {
return element
}
throw new Error('No component or element provided')
}
const ModalSafeAreaInsetsContext: FC<{
children: React.ReactNode
hasTopInset?: boolean
}> = ({ children, hasTopInset = true }) => {
const rootInsets = useSafeAreaInsets()
const rootFrame = useSafeAreaFrame()
return (
<SafeAreaFrameContext value={rootFrame}>
<SafeAreaInsetsContext
value={useMemo(
() => ({
...rootInsets,
top: hasTopInset ? rootInsets.top : 0,
}),
[hasTopInset, rootInsets],
)}
>
{children}
</SafeAreaInsetsContext>
</SafeAreaFrameContext>
)
}
这里需要判断的逻辑可能会有点复杂,需要区分 Stack 和 Modal 的类型,在 ModalStack 中又需要区分 formSheet 等等。同时每个 Modal 中有需要再包裹一层 StackScreen 等等。
从简单来说,就是需要根据 Navigation 的数据,生成对应的 <ScreenStackItem />
,然后传入到 <ScreenStack />
中。
这里的详细的代码均可在下面的链接中查看:
然后我们还需要处理 native navigation 的状态同步,主要在 native 触发 pop 和 dismiss 的时机发送的事件。在前面的文章中讲过,可以通过 ScreenStackItem
的 onDismissed
监听。
这里我们直接对 ScreenStackItem
再次封装。
export const WrappedScreenItem: FC<
{
screenId: string
children: React.ReactNode
stackPresentation?: StackPresentationTypes
screenOptions?: NavigationControllerViewExtraProps
style?: StyleProp<ViewStyle>
} & ScreenOptionsContextType
> = memo(
({
screenId,
children,
stackPresentation,
screenOptions: screenOptionsProp,
style,
...rest
}) => {
const navigation = useNavigation()
const screenOptionsCtxValue = useMemo<
PrimitiveAtom<ScreenOptionsContextType>
>(() => atom({}), [])
const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)
// Priority: Ctx > Define on Component
const mergedScreenOptions = useMemo(
() => ({
...screenOptionsProp,
...resolveScreenOptions(screenOptionsFromCtx),
}),
[screenOptionsFromCtx, screenOptionsProp],
)
const handleDismiss = useCallback(
(
e: NativeSyntheticEvent<{
dismissCount: number
}>,
) => {
if (e.nativeEvent.dismissCount > 0) {
for (let i = 0; i < e.nativeEvent.dismissCount; i++) {
navigation.__internal_dismiss(screenId)
}
}
},
[navigation, screenId],
)
const ref = useRef<View>(null)
return (
<ScreenItemContext value={ctxValue}>
<ScreenOptionsContext value={screenOptionsCtxValue}>
<ScreenStackItem
key={screenId}
screenId={screenId}
ref={ref}
stackPresentation={stackPresentation}
style={[StyleSheet.absoluteFill, style]}
{...rest}
{...mergedScreenOptions}
onDismissed={handleDismiss}
onNativeDismissCancelled={handleDismiss}
>
{children}
</ScreenStackItem>
</ScreenOptionsContext>
</ScreenItemContext>
)
},
)
export const PlayerScreen: NavigationControllerView = () => {
return <SheetScreen onClose={() => navigation.dismiss()}></SheetScreen>
}
PlayerScreen.transparent = true
那么现在我们就可以在 React 中使用 Navigation 了。
const navigation = useNavigation()
navigation.pushControllerView(PlayerScreen)
那么,一个简单的 Navigation 就完成了。
当然如果你有兴趣的话,也可以查看 Folo 这部分的完整实现,包括如何和 Bottom Tab 结合和页面 ScrollView 的联动。
2025-04-24 00:40:16
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/189
又是一个月过去了。
今年开始基本每个月只会写一篇手记了。生活中发生的事很少,表达欲也变淡了许多。
自上个月以来,这个月没有去太多地方。借群友的路过,在我家住了一晚,也为了过了一次生日,一起吃了饭,那天我也没有太孤独,很是感动。第二天跟着一起去了上海,在上海和朋友们吃了一顿日料自助,解锁了新的吃法,因为不吃生海鲜,把甜虾放到寿喜锅煮熟了吃也是一种美味。一下子炫了有二十只。还有其他的串、板烧之类的,自己还是挺能吃的。但是减肥一直是一个大问题了。(本来先找找有没有照片,发现一张都没拍。)
而之后的日子,再没有出过城了。最远的地方只是去县城的郊区和曾经的高中同学看了一次花。起因是 '浙江“阿勒泰” - 小红书',但是照骗,去了也没找到在哪。
终于把很多人吹爆的「葬送的芙莉莲」第一季追完了,最近联名活动也比较多,只是可惜我居住的地方没有这类活动。人类生命短暂,错过机会会带来遗憾,活在当下,珍惜与亲人共度的时光,这些时刻将成为珍贵的回忆。
https://www.themoviedb.org/tv/209867
上个月说会免费试用 FSD 一个月,但是后面被叫停了。心心念念想体验一次,问问很多次都不能在店里试乘。终于最近有机会能去体验了一把。但是从我的试乘感受上来说,并没有网上说的那么神。首先是,一开始就翻车了,直接闯了红灯,在中间紧急接管刹车。然后又是走错车道,左转走了直行道。最后,在一条施工道路也走错了,跨越实线进入了非机动车道。整体乘坐体验还是挺舒适的,但是科目一确实不及格。
不知道什么时候开始突然有了一个想法,想买个相机了。虽然我一点都不会拍照。之前在上海的朋友用了下富士之后,这个念头更加强烈了。但是在 2025 年的今天想买一个 2020 年生产的机器居然还要溢价购买,或者线下店抽签或者线上点踩点蹲抢真是离了个大谱。又是过了段时间,找了淘宝上的授权店,加了一个 35定镜头,原价购买了 1650 套机。然后学学摄像。没事开车出去可以拍拍景。发几张个人觉得还行的。
:::gallery
:::
工作上偶尔需要写写 Swift。空闲之余,我也在再一次入门 Apple 设备的 native 开发。而这次我选择重写两年前写的一个 macOS app - Process Reporter。这个 app 是用来上报当前我正在使用的 app 和 media 到 Mix Space 的。在我的网站上,呈现为:
两年前,我使用不成熟的 SwiftUI 糊了一个,全是 bug,功能也不太全面。而这次我选择用 AppKit 为主,SwiftUI 为辅去重写一个。经历了两周左右的马拉松和慢慢的打磨,现在功能基本差不多了。
这次加强了一下对 media process 的识别。现在可以准确的知道是哪个播放器在放歌了。(PS. macOS 在 15.4 及后续版本对私有接口加强了管制,导致后续版本无法正常识别任何 media 信息了)。另外加上了自动上传 app 的图标到图床的功能,没有预设的 app 也能正常显示图标。
最后,还有一个更好玩的。如果你使用 Slack 办公。那么就能实现这个效果。
非常简单的设置即可实现:
开源在:
2025-04-16 00:04:42
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2
上回说到,我们已经初步了解 React Native Screens 的 ScreenStackItem
用法。这节我们探索一下这个组件的原生实现,然后写一个简单的 Navigation 类。
ScreenStackItem
是怎么实现的进入 React Native Screens 的源码,我们找到这个组件的位置。
我们看到这里使用了 Screen
组件。继续查找。
const Screen = React.forwardRef<View, ScreenProps>((props, ref) => {
const ScreenWrapper = React.useContext(ScreenContext) || InnerScreen
return <ScreenWrapper {...props} ref={ref} />
})
Screen 组件使用了 InnerScreen
组件。它是对 NativeView 的进一步封装。
用到了 AnimatedScreen
,它针对不同场景使用的 Native 组件不同。但是最终都是一个 Screen 基类。
上面的 AnimatedNativeScreen
和 AnimatedNativeModalScreen
都是实实在在的原生组件了。他们分别对应的是 RNSScreen
和 RNSModalScreen
组件。
以 iOS 为例,找到其原生实现。通过 RSScreen.h
头文件看到,这个组件在原生中是一个 ViewController。所以它才会有这些生命周期事件。例如 viewDidLoad
、viewWillAppear
、viewDidAppear
等。
@interface RNSScreen : UIViewController <RNSViewControllerDelegate>
- (instancetype)initWithView:(UIView *)view;
- (UIViewController *)findChildVCForConfigAndTrait:(RNSWindowTrait)trait includingModals:(BOOL)includingModals;
- (BOOL)hasNestedStack;
- (void)calculateAndNotifyHeaderHeightChangeIsModal:(BOOL)isModal;
- (void)notifyFinishTransitioning;
- (RNSScreenView *)screenView;
#ifdef RCT_NEW_ARCH_ENABLED
- (void)setViewToSnapshot;
- (CGFloat)calculateHeaderHeightIsModal:(BOOL)isModal;
#endif
@end
而 RNSModalScreen
则是继承于 RNSScreen
的。
@interface RNSModalScreen : RNSScreenView
@end
这个为什么需要分别定义两个组件?
在 iOS 中,modal 和普通的 view 有所区别,modal 需要脱离 Root Navigation Controller 进入一个新的 Navigation Controller 中。它是一个孤立的视图。
那么,光有 ViewController 肯定是不行的。我们还缺少一个管理 VC 的 Navigation Controller。还记得之前我们在使用 ScreenStackItem
的时候,需要包一个 ScreenStack
吗?
我们找到它,在 iOS 中对应 RNSScreenStack
,它是一个 Navigation Controller。
@interface RNSNavigationController : UINavigationController <RNSViewControllerDelegate, UIGestureRecognizerDelegate>
@end
@interface RNSScreenStackView :
#ifdef RCT_NEW_ARCH_ENABLED
RCTViewComponentView <RNSScreenContainerDelegate>
#else
UIView <RNSScreenContainerDelegate, RCTInvalidating>
#endif
- (void)markChildUpdated;
- (void)didUpdateChildren;
- (void)startScreenTransition;
- (void)updateScreenTransition:(double)progress;
- (void)finishScreenTransition:(BOOL)canceled;
@property (nonatomic) BOOL customAnimation;
@property (nonatomic) BOOL disableSwipeBack;
#ifdef RCT_NEW_ARCH_ENABLED
#else
@property (nonatomic, copy) RCTDirectEventBlock onFinishTransitioning;
#endif // RCT_NEW_ARCH_ENABLED
@end
@interface RNSScreenStackManager : RCTViewManager <RCTInvalidating>
@end
现在 Navigation Controller 和 View Controller 都有了,那么 React Native Screens 是如何管理页面之间的切换并做出动画的呢。
我们知道在 iOS 中,使用在 Navigation Controller 上命令式调用 pushViewController
方法,就可以实现页面之间的切换。但是上一篇文章中的 demo 中,我们并没有调用任何原生方法,只是 React 这边的组件状态发生了更新。
还记得吗,回顾一下。
const Demo = () => {
const [otherRoutes, setOtherRoutes] = useState<
{
screenId: string
route: ReactNode
}[]
>([])
const cnt = useRef(0)
const pushNewRoute = useEventCallback(() => {
const screenId = `new-route-${cnt.current}`
cnt.current++
setOtherRoutes((prev) => [
...prev,
{
screenId,
route: (
<ScreenStackItem
style={StyleSheet.absoluteFill}
key={prev.length}
screenId={screenId}
onDismissed={() => {
setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
}}
>
<View className="flex-1 items-center justify-center bg-white">
<Text>New Route</Text>
</View>
</ScreenStackItem>
),
},
])
})
return (
<ScreenStack style={StyleSheet.absoluteFill}>
<ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
<View className="flex-1 items-center justify-center bg-white">
<Text>Root Route</Text>
<Button title="Push New Route" onPress={pushNewRoute} />
</View>
</ScreenStackItem>
{otherRoutes.map((route) => route.route)}
</ScreenStack>
)
}
我们只是通过更新 React Children 对应有几个 ScreenStackItem
组件,就实现了页面之间的切换。
那么,这个过程到底发生了什么呢?
其实都是在 RNSScreenStack
中处理的,通过比较更新前后的 children 数组,来决定是 push 还是 pop。
- (void)didUpdateReactSubviews
{
// we need to wait until children have their layout set. At this point they don't have the layout
// set yet, however the layout call is already enqueued on ui thread. Enqueuing update call on the
// ui queue will guarantee that the update will run after layout.
dispatch_async(dispatch_get_main_queue(), ^{
[self maybeAddToParentAndUpdateContainer];
});
}
- (void)maybeAddToParentAndUpdateContainer
{
BOOL wasScreenMounted = _controller.parentViewController != nil;
if (!self.window && !wasScreenMounted) {
// We wait with adding to parent controller until the stack is mounted.
// If we add it when window is not attached, some of the view transitions will be blocked (i.e.
// modal transitions) and the internal view controler's state will get out of sync with what's
// on screen without us knowing.
return;
}
[self updateContainer];
if (!wasScreenMounted) {
// when stack hasn't been added to parent VC yet we do two things:
// 1) we run updateContainer (the one above) – we do this because we want push view controllers to
// be installed before the VC is mounted. If we do that after it is added to parent the push
// updates operations are going to be blocked by UIKit.
// 2) we add navigation VS to parent – this is needed for the VC lifecycle events to be dispatched
// properly
// 3) we again call updateContainer – this time we do this to open modal controllers. Modals
// won't open in (1) because they require navigator to be added to parent. We handle that case
// gracefully in setModalViewControllers and can retry opening at any point.
[self reactAddControllerToClosestParent:_controller];
[self updateContainer];
}
}
- (void)updateContainer
{
NSMutableArray<UIViewController *> *pushControllers = [NSMutableArray new];
NSMutableArray<UIViewController *> *modalControllers = [NSMutableArray new];
for (RNSScreenView *screen in _reactSubviews) {
if (!screen.dismissed && screen.controller != nil && screen.activityState != RNSActivityStateInactive) {
if (pushControllers.count == 0) {
// first screen on the list needs to be places as "push controller"
[pushControllers addObject:screen.controller];
} else {
if (screen.stackPresentation == RNSScreenStackPresentationPush) {
[pushControllers addObject:screen.controller];
} else {
[modalControllers addObject:screen.controller];
}
}
}
}
[self setPushViewControllers:pushControllers];
[self setModalViewControllers:modalControllers];
}
当 React 组件的 children 发生变化会调用 didUpdateReactSubviews
方法。然后最后进入到 updateContainer
方法中。
在 updateContainer
方法中,会根据 RNSScreenView
的 stackPresentation
属性,来决定是 push 还是 pop。然后调用 setPushViewControllers
或者 setModalViewControllers
方法,来更新原生的视图。
在 setPushViewControllers
方法中调用原生的 pushViewController
方法。
所以,在 Native 中,整个 Navigation Controller 都是无状态的,他虽然存储 Controller 数组,但是只会比较前后得出需要过度的页面。
这也导致了,在 React 中如果你没有管理 ScreenStackItem,在触发 dismiss 之后,虽然看到页面返回了,但是再次点击进入之后就会推入比上一次 +1 的页面。
也因为这个原因,在 onDismissed 事件中,Native 无法告诉 React Native 这边被 dismiss 的页面是哪个,而是只能提供 dismiss 的数量。
onDismissed?: (e: NativeSyntheticEvent<{ dismissCount: number }>) => void;
好了,这篇文章就到这里了。篇幅已经有点长了。
那么,下一篇文章,我们再来实现一个简单的 Navigation 类吧。