React Native Expo的故事

    科技2025-03-26  11

    A few weeks ago, Jeti debuted Jeti Stories, a new way to interact with people around you. We can thank Snapchat for the original implementation, but at this point, stories have become intrinsic to what defines a social media network. It has been copied and recreated dozens of times by competing platforms, but users have come to take it for granted. Today, this is an essential part of any rising social app.

    几周前,Jeti推出了Jeti Stories,这是一种与您周围的人进行互动的新方式。 我们可以感谢Snapchat的最初实现,但在这一点上,故事已成为定义社交媒体网络的固有内容。 它已被竞争平台复制和重新创建了数十次,但用户已将其视为理所当然。 如今,这已成为任何新兴社交应用程序中必不可少的部分。

    Creating stories entirely in React Native is fairly straightforward, but doing so without detaching from Expo is more of a challenge. This article outlines how it’s done through three main components.

    完全在React Native中创建故事是相当简单的,但是要做到不脱离Expo而是一个更大的挑战。 本文概述了它是如何通过三个主要组件完成的。

    按钮 (The Buttons)

    Story buttons on Jeti Jeti上的故事按钮

    A story button is the touchable component that pulls up a modal responsible for displaying the story itself. The button is simple: it contains a profile picture and an outer “ring” that shows whether or not the story has been viewed. The outer ring uses Expo’s Linear Gradient, but rotates slowly using React Native’s animated library.

    故事按钮是可触摸的组件,它拉起负责显示故事本身的模式。 该按钮很简单:它包含个人资料图片和一个外部“环”,用于显示是否已查看故事。 外环使用Expo的线性渐变,但使用React Native的动画库缓慢旋转。

    First and foremost, we have the outer TouchableWithoutFeedback component. When a user presses the button, the animated wrapper around it scales down to 0.8x size. When they release, it animates back to its original scale.

    首先,我们有外部的TouchableWithoutFeedback组件。 当用户按下按钮时,其周围的动画包装器将缩小为0.8倍。 当它们释放时,它会重新设置其原始动画。

    <TouchableWithoutFeedback onPress={() => { if (!ignorePress) { this.handlePress(); } }} onPressIn={() => { if (!ignorePress) { Animated.spring(this.animatedScale, { toValue: 0.8, useNativeDriver: true, }).start(); } }} onPressOut={() => { if (!ignorePress) { Animated.spring(this.animatedScale, { toValue: 1, useNativeDriver: true, }).start(); } }} > <Animated.View style={{ transform: [{ scale: this.animatedScale, }], }} > ... button content ... </Animated.View> </TouchableWithoutFeedback>

    While this may be overlooked, this animation is an incredibly important detail. Personally, one of the most important parts of user experience is feedback. When a user creates an action, it’s our job to handle it appropriately. Without it, an interface feels dull, confusing, and unattractive.

    虽然这可能会被忽略,但是此动画是一个非常重要的细节。 就个人而言,用户体验中最重要的部分之一就是反馈。 当用户创建动作时,适当地处理它是我们的工作。 没有它,界面会变得沉闷,混乱和缺乏吸引力。

    When the press is finally registered using React Native’s logic, the onPress prop is called. This is independent of the other two props, so any logic that opens a story modal should be called here. The other two props can potentially be called while the user is horizontally scrolling the story FlatList.

    当最终使用React Native的逻辑注册印刷机时,将调用onPress属性。 这与其他两个道具无关,因此任何可以打开故事模式的逻辑都应在此处调用。 当用户水平滚动故事FlatList时,可能会调用其他两个道具。

    Next is the “outer ring” that signals whether or not the user has viewed the story or not. The logic is handled using simple ternary statement:

    接下来是“外圈”,该信号表示用户是否已查看故事。 使用简单的三元语句处理逻辑:

    {!viewed ? ( <AnimatedLinearGradient colors={[ colors.PINK, colors.PURPLE, ]} style={{ position: "absolute", width: size || 70, height: size || 70, borderRadius: (size || 70) / 2, transform: [{ rotate: this.animatedBackground.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"], }), }], }} /> ) : ( <View style={{ position: "absolute", width: size || 70, height: size || 70, borderRadius: (size || 70) / 2, backgroundColor: colorScheme === "dark" ? colors.BACKGROUND_CONTRAST_SECONDARY : colors.BACKGROUND_CONTRAST, }} /> )}

    Both of these components are located in the “background” of the story button. The inner content has a border width of 5px, creating the whitespace around it. This gives both background components the appearance of a “ring,” while technically occupying the entire region behind the inner content.

    这两个组件都位于故事按钮的“背景”中。 内部内容的边框宽度为5px,在其周围创建空白。 这使两个背景组件均具有“环”的外观,同时在技术上占据了内部内容后面的整个区域。

    Notice the AnimatedLinearGradient component that signifies a story that hasn’t been viewed. It’s animated rotation is handled by a loop initially called when the component mounts if the story hasn’t been viewed:

    注意AnimatedLinearGradient组件,该组件表示未查看的故事。 如果没有查看故事,则动画旋转是由在组件安装时最初调用的循环处理的:

    componentDidMount(): void { this.rotateBackground(); } rotateBackground = () => { const { viewed } = this.props; if (!viewed) { this.animatedBackground.setValue(0); Animated.timing(this.animatedBackground, { toValue: 1, useNativeDriver: true, duration: 3500, }).start(() => this.rotateBackground()); } };

    At this point, all we have is a spinning circle. The next step is to add the inner content.

    至此,我们所拥有的只是一个旋转的圈子。 下一步是添加内部内容。

    On Jeti, users have the ability to post anonymously to their local timeline. The same is true for stories, where users can add to their city’s story without disclosing their identity. This means that each city has its own story, and cities don’t have profile pictures. In order to handle this, a MapView from react-native-community/react-native-maps is used.

    在Jeti上,用户可以匿名发布到其本地时间线。 故事也是如此,用户可以在不泄露其身份的情况下添加自己城市的故事。 这意味着每个城市都有自己的故事,城市没有个人资料图片。 为了处理此问题,使用了来自react-native-community / react-native-maps的MapView 。

    {location ? ( <ProfileAvatar colorScheme={colorScheme} size={size ? (size - 10) : 60} hideBorder={true} elementOverride={( <MapView mapType={"mutedStandard"} followsUserLocation={true} style={{ position: "absolute", height: 120, width: 120, opacity: 0.6, transform: [{ scale: 0.5, }], }} initialRegion={{ ...location, latitudeDelta: 0.3, longitudeDelta: 0.3, }} /> )} /> ) : ( <ProfileAvatar colorScheme={colorScheme} size={size ? (size - 10) : 60} media={profile?.avatar} /> )}

    When a location object is passed as a prop to the StoryButton component, the ternary will render a ProfileAvatar component with the map as its inner content. The map is rendered at almost 2x the size of the button, then scaled down. This way, standard map footers from Apple and Google don’t take up most of the small frame. Instead, legal information for maps is shown on another screen.

    当将位置对象作为道具传递给StoryButton组件时,三元对象将渲染一个ProfileAvatar组件,并将地图作为其内部内容。 地图的渲染速度几乎是按钮的2倍,然后按比例缩小。 这样,Apple和Google的标准地图脚注就不会占用大部分小框架。 相反,地图的法律信息会显示在另一个屏幕上。

    The ProfileAvatar component is simple: it takes an optional media prop and renders it as a circle at the given size. If the media or elementOverride prop is undefined, it renders a standard user silhouette.

    ProfileAvatar组件很简单:它接受一个可选的媒体道具,并将其呈现为给定大小的圆形。 如果未定义media或elementOverride道具,则它将呈现标准用户剪影。

    Beneath the button is the story’s name. If this is the user’s local story, the text content is their city name (via expo-location reverse geocoding). If the story was posted by a user, this will be their username.

    该按钮下方是故事的名称。 如果这是用户的本地故事,则文本内容为他们的城市名称(通过expo-location 反向地理编码)。 如果故事是由用户发布的,则将是其用户名。

    Lastly, a blue circle with a “plus” icon is rendered at the bottom right of the button if the user or their city does not have a story. This shows the user that they can add to this story, and the camera opens when pressed.

    最后,如果用户或其所在城市没有故事,则按钮右下角将显示一个带有“加号”图标的蓝色圆圈。 这向用户显示了他们可以添加到该故事中的内容,并且在按下照相机时会打开照相机。

    故事情节 (The Story Modal)

    The header for a Jeti Story Jeti故事的标题

    A method by which users could view stories was difficult to implement perfectly. Initially, I thought that opening an independent modal for each story would work. This way, when a user clicked on the story button, it would call this modal. However, users are used to swiping back and forth between stories, so this had to be one, single modal containing every story available to the user.

    用户观看故事的方法很难完美实现。 最初,我认为为每个故事打开一个独立的模式是可行的。 这样,当用户单击故事按钮时,它将称为此模式。 但是,用户习惯于在故事之间来回滑动,因此必须是一个单一模式,其中包含用户可用的每个故事。

    To do this, Redux handles the local state containing the active story data in memory. When the story header containing the buttons mounts, it queries the API for available stories from profiles nearby, profiles that the user follows, and the user’s location. It then adds them to the GlobalState, which this modal uses to render data on its FlatList.

    为此, Redux 处理内存中包含活动故事数据的本地状态。 装入包含按钮的故事标题后,它将查询API,以获取附近的个人资料,用户关注的个人资料以及用户的位置中的可用故事。 然后,将它们添加到GlobalState ,此模式将其用于在FlatList上呈现数据。

    When a user presses on a story button, the StoryModal is called with the initialIndex prop to specify which story the user intends to view.

    用户按下故事按钮时,将使用initialIndex属性调用StoryModal,以指定用户打算查看的故事。

    onModalShow={() => { this.setState({ ...this.state, currentIndex: initialIndex || 0, }, () => { const { currentIndex } = this.state; if (this.flatList) { this.flatList.scrollToOffset({ offset: currentIndex * width, animated: true, }); } }); }}

    This function is passed as a prop to the Modal component, provided by react-native-community/react-native-modal. By using onModalShow, this is called each time the modal reappears.

    此功能作为prop传递给由react-native-community / react-native-modal提供的Modal组件。 通过使用onModalShow ,每次模态重新出现时都会调用此方法。

    Here is the FlatList responsible for rendering each story:

    这是负责呈现每个故事的FlatList :

    <FlatList ref={ref => this.flatList = ref} onStartShouldSetResponderCapture={() => true} initialNumToRender={1} maxToRenderPerBatch={1} initialScrollIndex={initialIndex} horizontal={true} bounces={false} data={keys} keyExtractor={(item) => item} snapToInterval={width} decelerationRate={"fast"} getItemLayout={(data, index) => ({ length: width, offset: width * index, index, })} scrollEventThrottle={16} onScroll={({ nativeEvent: { contentOffset } }) => { const newIndex = Math.round(contentOffset.x / width); this.scrollIndex = newIndex; if (currentIndex !== newIndex) { this.setState({ ...this.state, currentIndex: newIndex, }); } }} renderItem={({ item, index }) => { if (stories[item] && stories[item].length) { return ( <ViewStorySet key={index} colorScheme={colorScheme} items={stories[item]} geocode={item === "location" ? geocode : undefined} location={item === "location" ? data : undefined} onComplete={(reverse) => { if (reverse && this.flatList && index - 1 >= 0) { return this.flatList.scrollToOffset({ offset: (index - 1) * width, }); } else if (this.flatList && index + 1 < keys.length) { return this.flatList.scrollToOffset({ offset: (index + 1) * width, }); } GlobalState.dispatch({ type: "STORY_SORT_BY_VIEWED", }); toggleModal(); }} isViewing={isVisible && (index === currentIndex)} toggleModal={() => { GlobalState.dispatch({ type: "STORY_SORT_BY_VIEWED", }); toggleModal(); }} /> ); } else { return ( <View style={{ width, height, backgroundColor: "black", }}> <NewStoryModal colorScheme={colorScheme} isLocation={item === "location"} inline={true} toggleModal={toggleModal} /> </View> ); } }} />

    There’s obviously a lot going on here, but the important parts are located in onScroll and renderItem. If the story does not exist (such as when the user, or their city, does not have a story), the NewStoryModal is rendered. This modal is straightforward and contains the camera and story editor.

    显然有很多事情要做,但是重要的部分位于onScroll和renderItem中。 如果故事不存在(例如,当用户或其所在城市没有故事时),则呈现NewStoryModal 。 该模式很简单,包含摄影机和故事编辑器。

    If the story exists, it renders the ViewStorySet component. This is the most complex component being used for Jeti Stories. It must handle a story with multiple media items, timers, animations, and multiple touchable components.

    如果故事存在,它将呈现ViewStorySet组件。 这是Jeti Stories使用的最复杂的组件。 它必须处理包含多个媒体项,计时器,动画和多个可触摸组件的故事。

    故事集 (Story Sets)

    Right off the bat, there are multiple class variables that are instantiated by the ViewStorySet class.

    马上, ViewStorySet类实例化了多个类变量。

    private video: Video|null = null; private startedViewing = 0; private animatedProgressBar = [...Array(this.props.items.length)].map(() => new Animated.Value(0)); private animatedProgressBarAnimation: Animated.CompositeAnimation|null = null;

    The video variable is self-explanatory- this is used only when the story media is a video, of course. The startedViewing variable is used to represent a date (in ms since epoch) that the user started viewing the story. This is used to signify whether the user actually viewed a story by checking the duration that they viewed it.

    video变量是不言自明的-当然,仅当故事媒体是视频时才使用。 startsViewing变量用于表示用户开始查看故事的日期(自纪元以来的毫秒数)。 这用于通过检查用户查看故事的持续时间来表示用户是否实际查看了故事。

    The animatedProgressBar creates an animated value for each story item within this story set. This way, the value starts at zero and can be stopped, resumed, and always used to represent the progress bar for whatever story is at a given index. The animatedProgressBarAnimation is the timer that animates the progress bar itself. By setting it as a class variable, it can be started and stopped anywhere within the class.

    animationProgressBar为该故事集中的每个故事项创建一个动画值。 这样,该值从零开始,可以停止,恢复,并且始终用于表示给定索引下的任何故事的进度条。 animationProgressBarAnimation是对进度条本身进行动画处理的计时器。 通过将其设置为类变量,可以在类中的任何位置启动和停止它。

    The runProgressBar function is called once the story media has loaded. It starts the animatedProgressBar animation, and its duration is set to either 10 seconds or the story’s video length.

    故事媒体加载后,将调用runProgressBar函数。 它开始animatedProgressBar动画,其持续时间被设置为10秒故事的视频长度。

    runProgressBar = (duration?: number) => { const { currentIndex } = this.state; this.startedViewing = Date.now(); this.animatedProgressBarAnimation = Animated.timing(this.animatedProgressBar[currentIndex], { toValue: 1, duration: duration || 10000, useNativeDriver: false, }); if (this.video) { this.video.playAsync(); } this.animatedProgressBarAnimation.start(({ finished }) => { const diff = Date.now() - this.startedViewing; if (diff > 750) { // Viewed story for (diff / 1000) seconds this.markAsViewed(); } if (finished) { this.onNextItem(); } }); };

    Finally, here are the components that are rendered by ViewStorySet:

    最后,这是ViewStorySet呈现的组件:

    The background: a base64 25x25 preview image, sent as part of the API response containing the story payload, behind a blur view.

    背景:Base64 25x25预览图像,作为API响应的一部分发送,其中包含故事有效内容,位于模糊视图后面。

    An activity indicator: while the story is loading, the background is shown representing the average colors of the story media. In front of it is an ActivityIndicator, which shows the user that the media is downloading.

    活动指示器:加载故事时,显示的背景代表故事媒体的平均颜色。 它前面是一个ActivityIndi​​cator ,它向用户显示媒体正在下载。

    The overlay: as seen in the image above, this shows the progress bar, the user’s profile picture and name, and an “x” to close the story modal. In the bottom left corner there’s an “eye” icon and the number of users that have viewed that story. If the user posted the story, the bottom right has a “horizontal three dots” icon, pulling up a bottom sheet that allows them to delete the story.

    叠加层:如上图所示,它显示进度条,用户的个人资料图片和姓名,以及用于关闭故事模式的“ x”。 左下角有一个“眼睛”图标,并且显示了该故事的用户数量。 如果用户发布了故事,则右下角将显示一个“水平三点”图标,从而拉起一个允许他们删除该故事的底页。 Two touchable components: these invisible components take up the entire height of the screen and exactly half of the screen width each, side by side. When the left one is pressed, the user is taken back to the previous item in the story set, if there is one. If not, they’ll be taken back to the previous story from another user. When the right one is pressed, the opposite takes place.

    两个可触摸的组件:这些不可见的组件并排占据屏幕的整个高度和每个屏幕宽度的一半。 如果按下左一个,则将用户带回到故事集中的上一项(如果有的话)。 如果不是,它们将被另一个用户带回到上一个故事。 按下右边的按钮时,相反的情况发生。

    The story media: depending on the media type, either an Image or Video component is rendered.

    故事媒体:根据媒体类型,将呈现“图像”或“视频”组件。

    {item.media.type === MediaType.Image ? ( <Image style={{ width, height, resizeMode: "contain", }} onError={({ nativeEvent: { error } }) => { CaptureError(new Error(error)); if (isViewing) { this.onNextItem(); } }} onLoad={() => { console.log("[ViewStorySet] Loaded Image."); if (isViewing) { this.runProgressBar(); } this.setState({ ...this.state, loaded: true, }); }} resizeMode={Math.abs((item.media.width / item.media.height) - (width / height)) > 0.1 ? "contain" : "cover"} source={this.getSource(item)} /> ) : ( <Video ref={ref => this.video = ref} source={this.getSource(item)} onError={(error) => { CaptureError(new Error(error)); if (isViewing) { this.onNextItem(); } }} onLoad={(data) => { console.log("[ViewStorySet] Loaded video."); if (isViewing && data.isLoaded && data.durationMillis) { this.runProgressBar(data.durationMillis); } this.setState({ ...this.state, loaded: true, }); }} useNativeControls={false} onPlaybackStatusUpdate={(status) => { if (status.isLoaded) { if (status.positionMillis === status.durationMillis) { this.onNextItem(); } } }} rate={1} volume={1} isMuted={false} resizeMode={Math.abs((item.media.width / item.media.height) - (width / height)) > 0.1 ? "contain" : "cover"} shouldPlay={false} isLooping={false} style={{ borderRadius: 15, width, height, }} /> )}

    There are a few things going on here. First, the resizeMode prop is used to either cover the story area or contain the media within it, depending on the dimensions of the user’s device. If the difference between the media and device’s aspect ratio is greater than 0.1, the media is contained within the story frame. Otherwise, it covers the user’s screen.

    这里发生了一些事情。 首先,根据用户设备的尺寸, resizeMode属性用于覆盖故事区域或在其中包含媒体。 如果媒体和设备的宽高比之间的差异大于0.1,则媒体将包含在故事框架中。 否则,它将覆盖用户的屏幕。

    This is useful for displaying stories from phones with very similar screen sizes. For example, if someone takes a photo on their iPhone 11, another user on an iPhone 11 Pro Max will see the story occupying the entire frame. However, if the story is taken on an iPhone 6 which has very different dimensions, it will be contained. It will occupy the full width of the device, but the blurred background will appear at the top and bottom of the screen.

    这对于显示屏幕尺寸非常相似的手机中的故事很有用。 例如,如果有人在iPhone 11上拍照,则另一个iPhone 11 Pro Max的用户将看到故事占据了整个画面。 但是,如果故事是在尺寸不同的iPhone 6上拍摄的,则将包含该故事。 它会占据设备的整个宽度,但是模糊的背景将出现在屏幕的顶部和底部。

    Next, both onLoad functions are called to activate the progress bar. This way, the progress bar won’t run unless the story media is actually being displayed. In the case of a video, data.durationMillis is passed to this.runProgressBar. This way, the progress bar will run for as long as the story video is playing, not the 10 second default for images.

    接下来,调用两个onLoad函数来激活进度条。 这样,除非实际显示故事媒体,否则进度条将不会运行。 对于视频, data.durationMillis将传递到this.runProgressBar。 这样,进度条将在播放故事视频的过程中一直运行,而不是图像的默认10秒。

    结论 (Conclusion)

    People love the idea of short, temporary posts that don’t clutter their timeline. But, moving away from just social media platforms, this can be useful for any platform that relies on user-generated content and interaction. Social-based fitness apps, travel platforms, and even hobby forums can all have a similar implementation to promote people sharing their experiences. While someone may shy away from creating a permanent post, temporary content requires less thought and decision making.

    人们喜欢简短,临时的帖子的想法,这些帖子不会使他们的时间安排混乱。 但是,远离社交媒体平台,这对于依赖于用户生成的内容和交互的任何平台都是有用的。 基于社交的健身应用程序,旅行平台甚至兴趣论坛都可以采用类似的实现方式来促进人们分享经验。 尽管有人可能会回避创建永久职位,但临时内容需要较少的思考和决策。

    While there are many other moving parts involved in any “story” implementation, these components were the most difficult to create along the way. Hopefully this article can be of some help to anyone working on a similar system, but in no way is it perfect. So, feel free to leave some feedback or suggestions below.

    尽管任何“故事式”实现中都涉及许多其他活动部分,但是在此过程中最难创建这些组件。 希望本文对使用类似系统的人有所帮助,但绝不是完美的。 因此,请随时在下面留下一些反馈或建议。

    翻译自: https://medium.com/jeti-app/stories-in-react-native-expo-a5c41652faad

    相关资源:EXPO XDE ReactNative 调试工具 windowsPC 版本
    Processed: 0.011, SQL: 8