教程:如何使用 React Native 构建 Slack 克隆(第一部分)
React Native在移动开发领域占有重要地位。随着每个新版本的发布,它的开发速度和性能也越来越出色。构建一个聊天应用程序曾经是一项艰巨的工作,但借助 React-native 和Stream Chat的强大功能,只需几分钟即可创建一个消息应用程序。
在本教程中,我们将构建一个 Slack 的克隆版本,Slack 是一个面向工作场所的消息平台。Slack 应用程序具有丰富的功能。在本教程的这一部分,我们将介绍 Slack 的以下 UI/UX 功能:
- 频道列表导航
- 输入框
- 消息行
- 反应列表
- Giphy 卡片
- 丰富的 URL 预览
结果将如下所示:
注意:本教程的目的并非帮助您构建一个可用于生产的 Slack 应用程序克隆版本(因为它已经存在)。相反,本教程将作为一个入门指南,帮助您了解如何使用 Stream 的聊天和消息传递 API 及 SDK 提供的 UI 组件构建真实的聊天功能。
如果您在本教程中感到迷茫,以下资源将会有所帮助:
注意:如果您想向应用程序发送消息,以检查实时功能是否正常工作,请使用此CodePen。
资源👇
如果您在途中遇到困难,以下几个链接可以为您提供帮助:
快速测试🥽
如果您希望快速查看应用程序的最终运行状态,请克隆以下 slack clone 的 expo 示例并在模拟器或手机上运行它:
步骤 1:设置🛠️
开发环境设置
在开始之前,请确保你已经为 react-native 设置好了开发环境。请阅读react-native 官方文档的“安装依赖项”部分。
项目设置
设置好开发环境后,创建一个新的 react-native 应用程序:
Slack 使用 Lato 字体,该字体可在https://fonts.google.com/上免费获取。为了达到视觉效果,我们需要将该字体导入到我们的应用中。为此,请react-native.config.js在项目目录中创建一个名为的文件,并粘贴以下内容:
slack-clone您可以从项目存储库下载 Lato 字体文件,并从这里下载图标。
或者,您可以从Google Fonts 网站下载字体。您会Download family在顶部看到一个标题按钮。
接下来在项目根目录下准备如下目录结构:
请在此步骤运行以下命令:
完成这些步骤后,您的 slack-clone 应用所需的设置就完成了。现在,您应该能够使用以下命令在模拟器上启动该应用。启动后,您将看到 React Native 的欢迎屏幕。
步骤 2:组件
基本导航抽屉
让我们首先在应用中创建一个基本的抽屉导航。将 的内容替换App.js为以下代码:
完成此操作后,如果您检查模拟器,您应该会看到基本的类似 Slack 的抽屉导航。
频道列表导航🧭
现在,让我们创建一个频道列表导航,并将其添加到刚刚创建的抽屉中。对于 Slack 导航抽屉,我们将重点关注以下一些基本 UI 元素:
- 频道分组依据 
  - 未读频道
- 频道(读取频道)
- 直接消息 - 这是react-native 中SectionList的完美用例
 
- 未读频道标签以粗体显示
- 直接消息用户的名字旁边有一个在线指示器 - 如果他们在线则为绿色,否则为空心圆圈。
让我们创建一个名为 的文件src/components/ChannelList.js。您可以将以下代码片段的内容复制到新创建的文件中:
此外,用以下内容替换ChannelListDrawer组件App.js:
如果你熟悉 React-native,这段代码应该相当直观。我们添加了一个SectionList包含三个部分的组件:未读消息、频道消息、私信消息。目前你应该在应用中看到以下内容:
现在让我们填充SectionList一些频道。正如我在本教程前面提到的,我们将使用 Stream 的聊天基础架构。
让我们首先创建一个 Stream Chat 客户端App.js并将其作为 prop 传递给ChannelList组件。
我们还添加了一个名为 的 prop 函数changeChannel,它负责打开频道屏幕并将提供的频道 ID 传递给它。我们将使用此函数作为onPress的处理程序ChannelListItem。
现在让我们在ChannelList.js文件中创建一个钩子,用于查询频道。稍后,当有新消息到达或在群组之间移动消息时,我们会实时更新它们。
如果您不熟悉 React hooks,这里有一些很棒的入门资源:
此钩子使用 Stream 客户端查询频道。它将频道分为三类,并作为状态变量返回unreadChannels:readChannelsoneOnOneConversations
该renderChannelListItem函数当前返回<Text>{channel.id}</Text>,显示频道的 ID。让我们为该项目创建一个类似 Slack 的 UI。
在名为 的单独文件中创建一个新组件src/components/ChannelListItem.js。
该组件将根据群组频道、一对一对话或未读频道来确保不同的样式。它还会检查是否包含用户提及。
现在让我们在组件ChannelListItem中使用我们的组件。ChannelListSectionList
| import React, {useState, useEffect} from 'react'; | |
| import { | |
| View, | |
| Text, | |
| SafeAreaView, | |
| TextInput, | |
| StyleSheet, | |
| SectionList, | |
| } from 'react-native'; | |
| import {ChannelListItem} from './ChannelListItem'; | |
| export const ChannelList = ({client, changeChannel}) => { | |
| const { | |
| activeChannelId, | |
| setActiveChannelId, | |
| unreadChannels, | |
| readChannels, | |
| oneOnOneConversations, | |
| } = useWatchedChannels(client, changeChannel); | |
| const renderChannelRow = (channel, isUnread) => { | |
| const isOneOnOneConversation = | |
| Object.keys(channel.state.members).length === 2; | |
| return ( | |
| <ChannelListItem | |
| activeChannelId={activeChannelId} | |
| setActiveChannelId={setActiveChannelId} | |
| changeChannel={changeChannel} | |
| isOneOnOneConversation={isOneOnOneConversation} | |
| isUnread={isUnread} | |
| channel={channel} | |
| client={client} | |
| key={channel.id} | |
| currentUserId={client.user.id} | |
| /> | |
| ); | |
| }; | |
| return ( | |
| <SafeAreaView> | |
| <View style={styles.container}> | |
| <View style={styles.headerContainer}> | |
| <TextInput | |
| style={styles.inputSearchBox} | |
| placeholderTextColor="grey" | |
| placeholder="Jump to" | |
| /> | |
| </View> | |
| <SectionList | |
| style={styles.sectionList} | |
| sections={[ | |
| { | |
| title: 'Unread', | |
| id: 'unread', | |
| data: unreadChannels || [], | |
| }, | |
| { | |
| title: 'Channels', | |
| data: readChannels || [], | |
| }, | |
| { | |
| title: 'Direct Messages', | |
| data: oneOnOneConversations || [], | |
| }, | |
| ]} | |
| keyExtractor={(item, index) => item.id + index} | |
| renderItem={({item, section}) => { | |
| return renderChannelRow(item, section.id === 'unread'); | |
| }} | |
| renderSectionHeader={({section: {title}}) => ( | |
| <View style={styles.groupTitleContainer}> | |
| <Text style={styles.groupTitle}>{title}</Text> | |
| </View> | |
| )} | |
| /> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| }; | 
正如你在这里注意到的,我提供了isUnread: true未读部分的数据。这样,我就可以告诉renderChannelRow函数当前要渲染的通道是否为未读通道。
renderChannelRow这不是必需的,因为您可以快速获取正在使用的频道的未读计数,channel.unreadCount()以判断该消息是否已读。但这只是为了避免额外调用channel.countUnread(),这本质上是循环遍历消息。
如果您重新加载应用程序,您应该会看到频道列表中填充了几个频道,如下面的屏幕截图所示:
到目前为止,ChannelList一切正常,但你会注意到它不是实时的。如果其他用户在某个频道发送了消息,它不会反映在你的 上。为此,ChannelList我们需要在钩子中实现事件处理程序。useWatchedChannels
您可以在此处找到有关 Stream 事件的详细文档。
出于教程目的,我们将处理两个事件,但您可以根据需要尝试任意数量的事件:
- message.new- 此事件告诉我们某个频道上有一条新消息(频道数据包含在事件对象中)。在本例中,我们希望将频道从- readChannels或- oneOnOneConversations移动到- unreadChannels。
- message.read- 此事件告诉我们某个通道(事件对象中可用的数据)被标记为已读。在本例中,我们希望将通道从 移动- unreadChannels到- readChannels或- oneOnOneConversations。
将钩子代码替换useWatchedChannels为以下更新的代码:
| const useWatchedChannels = (client, changeChannel) => { | |
| const [activeChannelId, setActiveChannelId] = useState(null); | |
| const [unreadChannels, setUnreadChannels] = useState([]); | |
| const [readChannels, setReadChannels] = useState([]); | |
| const [oneOnOneConversations, setOneOnOneConversations] = useState([]); | |
| const [hasMoreChannels, setHasMoreChannels] = useState(true); | |
| const filters = { | |
| type: 'messaging', | |
| example: 'slack-demo', | |
| members: { | |
| $in: [client.user.id], | |
| }, | |
| }; | |
| const sort = {has_unread: -1, cid: -1}; | |
| const options = {limit: 30, state: true}; | |
| useEffect(() => { | |
| if (!hasMoreChannels) { | |
| return; | |
| } | |
| let offset = 0; | |
| const _unreadChannels = []; | |
| const _readChannels = []; | |
| const _oneOnOneConversations = []; | |
| /** | |
| * fetchChannels simply gets the channels from queryChannels endpoint | |
| * and sorts them by following 3 categories: | |
| * | |
| * - Unread channels | |
| * - Channels (read channels) | |
| * - Direct conversations/messages | |
| */ | |
| async function fetchChannels() { | |
| const channels = await client.queryChannels(filters, sort, { | |
| ...options, | |
| offset, | |
| }); | |
| offset = offset + channels.length; | |
| channels.forEach((c) => { | |
| if (c.countUnread() > 0) { | |
| _unreadChannels.push(c); | |
| } else if (Object.keys(c.state.members).length === 2) { | |
| _oneOnOneConversations.push(c); | |
| } else { | |
| _readChannels.push(c); | |
| } | |
| }); | |
| setUnreadChannels([..._unreadChannels]); | |
| setReadChannels([..._readChannels]); | |
| setOneOnOneConversations([..._oneOnOneConversations]); | |
| if (channels.length === options.limit) { | |
| fetchChannels(); | |
| } else { | |
| setHasMoreChannels(false); | |
| setActiveChannelId(_readChannels[0].id); | |
| changeChannel(_readChannels[0].id); | |
| } | |
| } | |
| fetchChannels(); | |
| }, [client]); | |
| useEffect(() => { | |
| function handleEvents(e) { | |
| if (e.type === 'message.new') { | |
| const cid = e.cid; | |
| // Check if the channel (which received new message) exists in group channels. | |
| const channelReadIndex = readChannels.findIndex( | |
| (channel) => channel.cid === cid, | |
| ); | |
| if (channelReadIndex >= 0) { | |
| // If yes, then remove it from reacChannels list and add it to unreadChannels list | |
| const channel = readChannels[channelReadIndex]; | |
| readChannels.splice(channelReadIndex, 1); | |
| setReadChannels([...readChannels]); | |
| setUnreadChannels([channel, ...unreadChannels]); | |
| } | |
| // Check if the channel (which received new message) exists in oneOnOneConversations list. | |
| const oneOnOneConversationIndex = oneOnOneConversations.findIndex( | |
| (channel) => channel.cid === cid, | |
| ); | |
| if (oneOnOneConversationIndex >= 0) { | |
| // If yes, then remove it from oneOnOneConversations list and add it to unreadChannels list | |
| const channel = oneOnOneConversations[oneOnOneConversationIndex]; | |
| oneOnOneConversations.splice(oneOnOneConversationIndex, 1); | |
| setOneOnOneConversations([...oneOnOneConversations]); | |
| setUnreadChannels([channel, ...unreadChannels]); | |
| } | |
| // Check if the channel (which received new message) already exists in unreadChannels. | |
| const channelUnreadIndex = unreadChannels.findIndex( | |
| (channel) => channel.cid === cid, | |
| ); | |
| if (channelUnreadIndex >= 0) { | |
| const channel = unreadChannels[channelUnreadIndex]; | |
| unreadChannels.splice(channelUnreadIndex, 1); | |
| setReadChannels([...readChannels]); | |
| setUnreadChannels([channel, ...unreadChannels]); | |
| } | |
| } | |
| if (e.type === 'message.read') { | |
| if (e.user.id !== client.user.id) { | |
| return; | |
| } | |
| const cid = e.cid; | |
| // get channel index | |
| const channelIndex = unreadChannels.findIndex( | |
| (channel) => channel.cid === cid, | |
| ); | |
| if (channelIndex < 0) { | |
| return; | |
| } | |
| // get channel from channels | |
| const channel = unreadChannels[channelIndex]; | |
| unreadChannels.splice(channelIndex, 1); | |
| setUnreadChannels([...unreadChannels]); | |
| if (Object.keys(channel.state.members).length === 2) { | |
| setOneOnOneConversations([channel, ...oneOnOneConversations]); | |
| } else { | |
| setReadChannels([channel, ...readChannels]); | |
| } | |
| } | |
| } | |
| client.on(handleEvents); | |
| return () => { | |
| client.off(handleEvents); | |
| }; | |
| }, [client, readChannels, unreadChannels, oneOnOneConversations]); | |
| return { | |
| activeChannelId, | |
| setActiveChannelId, | |
| unreadChannels, | |
| setUnreadChannels, | |
| readChannels, | |
| setReadChannels, | |
| oneOnOneConversations, | |
| setOneOnOneConversations, | |
| }; | |
| }; | 
我们在这里添加了另一个useEffect钩子,它将事件监听器添加到我们的流客户端,并在组件卸载时负责移除该监听器。这handleEvent是一个事件处理程序,它会根据接收到的事件采取相应的操作。
作为练习,您可以尝试添加其他事件的处理程序,例如
user.presence.changed,channel.updated或channel.deleted
现在尝试从这个CodePen向某个频道发送一条消息(使用用户Tommaso),你应该会看到带有新消息的频道移至未读部分。
现在我们需要处理的最后一件事是的onclick处理程序ChannelListItem。当选择一个项目时,我们需要更新中的通道ChannelScreen。
我们的组件到此就完成了ChannelList。如果你向此列表中的某个频道发送一条消息,你将看到事件处理程序执行相应的操作,并更新列表 UI。
频道屏幕📱
让我们首先构建如下所示的频道标题:
为标题创建一个新文件 - src/components/ChannelHeader.js:
| import React from 'react'; | |
| import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native'; | |
| import iconSearch from '../images/icon-search.png'; | |
| import iconThreeDots from '../images/icon-3-dots.png'; | |
| export const ChannelHeader = ({navigation, channel, client}) => { | |
| let channelTitle = '#channel_name'; | |
| // For normal group channel/conversation, its channel name as display title. | |
| if (channel && channel.data && channel.data.name) { | |
| channelTitle = '# ' + channel.data.name.toLowerCase().replace(' ', '_'); | |
| } | |
| const memberIds = | |
| channel && channel.state ? Object.keys(channel.state.members) : []; | |
| // Check if its oneOneOneConversation. | |
| if (channel && memberIds.length === 2) { | |
| // If yes, then use name of other user in conversation as channel display title. | |
| const otherUserId = | |
| memberIds[0] === client.user.id ? memberIds[1] : memberIds[0]; | |
| channelTitle = channel.state.members[otherUserId].user.name; | |
| } | |
| return ( | |
| <View style={styles.container}> | |
| <View style={styles.leftContent}> | |
| <TouchableOpacity | |
| onPress={() => { | |
| navigation.openDrawer(); | |
| }}> | |
| <Text style={styles.hamburgerIcon}>☰</Text> | |
| </TouchableOpacity> | |
| <Text style={styles.channelTitle}>{channelTitle}</Text> | |
| </View> | |
| {/* Message search and menu popup are not functional here. We will cover them in some future tutorial. */} | |
| <View style={styles.rightContent}> | |
| <TouchableOpacity style={styles.searchIconContainer}> | |
| <Image source={iconSearch} style={styles.searchIcon} /> | |
| </TouchableOpacity> | |
| <TouchableOpacity style={styles.menuIconContainer}> | |
| <Image source={iconThreeDots} style={styles.menuIcon} /> | |
| </TouchableOpacity> | |
| </View> | |
| </View> | |
| ); | |
| }; | |
| export const styles = StyleSheet.create({ | |
| container: { | |
| padding: 15, | |
| flexDirection: 'row', | |
| backgroundColor: 'white', | |
| justifyContent: 'space-between', | |
| borderBottomWidth: 0.5, | |
| borderBottomColor: 'grey', | |
| }, | |
| leftContent: { | |
| flexDirection: 'row', | |
| }, | |
| hamburgerIcon: { | |
| fontSize: 27, | |
| }, | |
| channelTitle: { | |
| color: 'black', | |
| marginLeft: 10, | |
| fontWeight: '900', | |
| fontSize: 17, | |
| fontFamily: 'Lato-Regular', | |
| }, | |
| rightContent: { | |
| flexDirection: 'row', | |
| marginRight: 10, | |
| }, | |
| searchIconContainer: {marginRight: 15, alignSelf: 'center'}, | |
| searchIcon: { | |
| height: 18, | |
| width: 18, | |
| }, | |
| menuIcon: { | |
| height: 18, | |
| width: 18, | |
| }, | |
| menuIconContainer: {alignSelf: 'center'}, | |
| }); | 
为此,我们在屏幕左侧添加了一个汉堡图标,单击该图标将打开导航抽屉。
我们尚未将其放入ChannelHeader我们的组件中ChannelScreen。
使用以下内容更新ChannelScreen组件:App.js
| import {ChannelHeader} from './src/components/ChannelHeader'; | |
| import React, {useEffect, useState} from 'react'; | |
| function ChannelScreen({navigation, route}) { | |
| const [channel, setChannel] = useState(null); | |
| useEffect(() => { | |
| if (!channel) { | |
| navigation.openDrawer(); | |
| } | |
| const channelId = route.params ? route.params.channelId : null; | |
| const _channel = chatClient.channel('messaging', channelId); | |
| setChannel(_channel); | |
| }, [route.params]); | |
| return ( | |
| <SafeAreaView style={styles.channelScreenSaveAreaView}> | |
| <View style={styles.channelScreenContainer}> | |
| <ChannelHeader | |
| navigation={navigation} | |
| channel={channel} | |
| client={chatClient} | |
| /> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| } | 
如果您重新加载应用程序,您应该会看到一个空白的频道屏幕,顶部有标题:
现在让我们继续将MessageList和MessageInput组件添加到我们的ChannelScreen。
这两个组件由 Stream 作为react-native-sdk的一部分提供。
ChannelScreen请使用以下内容更新组件:
| import { | |
| Chat, | |
| MessageList, | |
| MessageInput, | |
| Channel, | |
| } from 'stream-chat-react-native'; | |
| function ChannelScreen({navigation, route}) { | |
| const [channel, setChannel] = useState(null); | |
| useEffect(() => { | |
| if (!channel) { | |
| navigation.openDrawer(); | |
| } | |
| const channelId = route.params ? route.params.channelId : null; | |
| const _channel = chatClient.channel('messaging', channelId); | |
| setChannel(_channel); | |
| }, [route.params]); | |
| return ( | |
| <SafeAreaView style={styles.channelScreenSaveAreaView}> | |
| <View style={styles.channelScreenContainer}> | |
| <ChannelHeader | |
| navigation={navigation} | |
| channel={channel} | |
| client={chatClient} | |
| /> | |
| <View style={styles.chatContainer}> | |
| <Chat client={chatClient}> | |
| <Channel channel={channel}> | |
| <MessageList /> | |
| <MessageInput /> | |
| </Channel> | |
| </Chat> | |
| </View> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| } | 
完成此更改后,您将在我们的频道屏幕底部看到消息和输入框。
但它看起来不太像 Slack 消息。所以现在我们需要做一些修改,让它看起来像 Slack。以下是 Slack UI 中与应用当前 UI 区别开来的部分。
- 用户名显示在消息顶部
- 头像(消息旁边的圆形用户个人资料图片)应该是正方形
- 回应应该在消息底部
- 每个反应旁边应显示反应计数
- URL 预览应该有一个粗的左灰色边框,并且其内容对齐偏移量
- 所有消息都应显示在屏幕左侧
- GIF 在 Slack 频道中的显示方式有所不同
- 消息之间的日期分隔符应显示在灰线上方
- 发送和附加按钮应位于输入框下方。
我们将逐一解决这些问题。Stream 的 React-native SDK 使用MessageSimple作为默认消息组件。但您也可以使用自定义 UI 组件作为消息组件——参考此处。
首先,让我们添加一些基本的自定义主题样式。让我们创建一个自定义消息组件(名为MessageSlack),该组件内部使用了 MessageSimple 并进行了修改。该MessageSimple组件提供了丰富的自定义功能。我们将为该组件支持的以下 props 创建自定义组件MessageSimple。
注意:请查看食谱以获取更多示例)
- 消息头像
- MessageFooter(包含反应)
- MessageHeader(包含发件人的用户名)
- 消息文本
- UrlPreview(用于显示丰富的 URL 预览)
- Giphy(用于显示 Giphy 卡片)
让我们创建每个组件:
src/components/MessageSlack.js
| import React from 'react'; | |
| import {MessageSimple} from 'stream-chat-react-native'; | |
| import {MessageFooter} from './MessageFooter'; | |
| import {MessageText} from './MessageText'; | |
| import {MessageAvatar} from './MessageAvatar'; | |
| import {MessageHeader} from './MessageHeader'; | |
| import {UrlPreview} from './UrlPreview'; | |
| import {Giphy} from './Giphy'; | |
| export const MessageSlack = props => { | |
| if (props.message.deleted_at) { | |
| return null; | |
| } | |
| return ( | |
| <MessageSimple | |
| {...props} | |
| forceAlign="left" | |
| ReactionList={null} | |
| MessageAvatar={MessageAvatar} | |
| MessageHeader={MessageHeader} | |
| MessageFooter={MessageFooter} | |
| MessageText={MessageText} | |
| UrlPreview={UrlPreview} | |
| Giphy={Giphy} | |
| /> | |
| ); | |
| }; | 
src/components/MessageFooter.js
src/components/MessageHeader.js
| import React from 'react'; | |
| import {View, Text, StyleSheet} from 'react-native'; | |
| import Moment from 'moment'; | |
| export const MessageHeader = props => { | |
| return ( | |
| <View style={styles.column}> | |
| {props.message.attachments.length > 0 && ( | |
| <View style={styles.header}> | |
| <MessageUserBar {...props} /> | |
| </View> | |
| )} | |
| </View> | |
| ); | |
| }; | |
| export const MessageUserBar = ({groupStyles, message}) => { | |
| if (groupStyles[0] === 'single' || groupStyles[0] === 'top') { | |
| return ( | |
| <View style={styles.userBar}> | |
| <Text style={styles.messageUserName}>{message.user.name}</Text> | |
| <Text style={styles.messageDate}> | |
| {Moment(message.created_at).format('hh:ss A')} | |
| </Text> | |
| </View> | |
| ); | |
| } | |
| return null; | |
| }; | |
| const styles = StyleSheet.create({ | |
| column: { | |
| flexDirection: 'column', | |
| }, | |
| header: { | |
| paddingLeft: 8, | |
| }, | |
| userBar: { | |
| flexDirection: 'row', | |
| alignItems: 'center', | |
| marginBottom: 5, | |
| }, | |
| messageUserName: { | |
| fontWeight: '900', | |
| color: 'black', | |
| fontSize: 15, | |
| fontFamily: 'Lato-Bold', | |
| }, | |
| messageDate: { | |
| color: 'grey', | |
| marginLeft: 6, | |
| fontSize: 10, | |
| }, | |
| }); | 
src/components/MessageText.js
| import React from 'react'; | |
| import {MessageUserBar} from './MessageHeader'; | |
| export const MessageText = props => { | |
| return ( | |
| <React.Fragment> | |
| {props.message.attachments.length === 0 && <MessageUserBar {...props} />} | |
| {props.renderText(props.message, props.theme.message.content.markdown)} | |
| </React.Fragment> | |
| ); | |
| }; | 
src/components/MessageAvatar.js
| import React from 'react'; | |
| import {MessageAvatar as StreamMessageAvatar} from 'stream-chat-react-native'; | |
| export const MessageAvatar = props => { | |
| return ( | |
| <StreamMessageAvatar | |
| {...props} | |
| showAvatar={ | |
| props.groupStyles[0] === 'single' || props.groupStyles[0] === 'top' | |
| ? true | |
| : false | |
| } | |
| /> | |
| ); | |
| }; | 
src/components/UrlPreview.js
| import React from 'react'; | |
| import {View, Text, TouchableOpacity, Image, StyleSheet} from 'react-native'; | |
| export const UrlPreview = props => { | |
| const getDomain = url => { | |
| let domain = url && url.replace('https://', '').replace('http://', ''); | |
| if (!domain) { | |
| return url; | |
| } | |
| const indexOfSlash = domain.indexOf('/'); | |
| if (indexOfSlash === -1) { | |
| return domain; | |
| } | |
| return domain.slice(0, indexOfSlash); | |
| }; | |
| return ( | |
| <TouchableOpacity style={styles.container}> | |
| <View style={styles.detailsContainer}> | |
| <Text style={styles.titleUrl}>{getDomain(props.title_link || props.og_scrape_url)}</Text> | |
| <Text style={styles.title}>{props.title}</Text> | |
| <Text style={styles.description}>{props.text}</Text> | |
| </View> | |
| <View style={styles.thumbnailContainer}> | |
| <Image | |
| source={{ | |
| url: props.image_url || props.thumb_url, | |
| }} | |
| style={styles.thumbnail} | |
| /> | |
| </View> | |
| </TouchableOpacity> | |
| ); | |
| }; | |
| const styles = StyleSheet.create({ | |
| container: { | |
| borderLeftWidth: 5, | |
| borderLeftColor: '#E4E4E4', | |
| paddingLeft: 10, | |
| marginLeft: 10, | |
| flexDirection: 'row', | |
| }, | |
| detailsContainer: { | |
| flexDirection: 'column', | |
| flex: 6, | |
| }, | |
| thumbnailContainer: { | |
| flex: 1, | |
| }, | |
| thumbnail: { | |
| height: 40, | |
| width: 40, | |
| }, | |
| titleUrl: { | |
| fontFamily: 'Lato-Regular', | |
| fontWeight: 'bold', | |
| padding: 2, | |
| }, | |
| title: { | |
| fontFamily: 'Lato-Regular', | |
| fontWeight: 'bold', | |
| color: '#1E75BE', | |
| padding: 2, | |
| }, | |
| description: { | |
| fontFamily: 'Lato-Regular', | |
| padding: 2, | |
| }, | |
| }); | 
src/components/Giphy.js
| import React from 'react'; | |
| import {Text, TouchableOpacity, Image, StyleSheet} from 'react-native'; | |
| export const Giphy = props => { | |
| return ( | |
| <TouchableOpacity style={styles.container}> | |
| <Text style={styles.title}>{props.title}</Text> | |
| <Text style={styles.description}>Posted using Giphy.com</Text> | |
| <Image | |
| source={{ | |
| url: props.image_url || props.thumb_url, | |
| }} | |
| style={styles.thumbnail} | |
| /> | |
| </TouchableOpacity> | |
| ); | |
| }; | |
| const styles = StyleSheet.create({ | |
| container: { | |
| borderLeftWidth: 5, | |
| borderLeftColor: '#E4E4E4', | |
| paddingLeft: 10, | |
| marginLeft: 10, | |
| marginBottom: 10, | |
| flexDirection: 'column', | |
| }, | |
| thumbnail: { | |
| height: 150, | |
| width: 250, | |
| borderRadius: 10, | |
| }, | |
| title: { | |
| fontFamily: 'Lato-Regular', | |
| fontWeight: 'bold', | |
| color: '#1E75BE', | |
| padding: 2, | |
| }, | |
| description: { | |
| fontFamily: 'Lato-Regular', | |
| padding: 2, | |
| fontSize: 13, | |
| fontWeight: '300', | |
| }, | |
| }); | 
我们还需要一个自定义DateSeparator组件。Stream 默认使用的组件会将日期显示在中间的空格/线中。然而,在 Slack UI 中,日期显示在顶部,并以灰色的空格/线显示。
src/components/DateSeparator.js
| import React from 'react'; | |
| import {View, Text, StyleSheet} from 'react-native'; | |
| import moment from 'moment'; | |
| export const DateSeparator = ({message}) => { | |
| return ( | |
| <View style={styles.container}> | |
| <Text style={styles.date}>{moment(message.date).calendar()}</Text> | |
| <View style={styles.line} /> | |
| </View> | |
| ); | |
| }; | |
| const styles = StyleSheet.create({ | |
| container: { | |
| flexDirection: 'column', | |
| marginBottom: 10, | |
| marginTop: 10, | |
| }, | |
| date: { | |
| fontWeight: 'bold', | |
| paddingBottom: 5, | |
| fontSize: 12, | |
| }, | |
| line: { | |
| flex: 1, | |
| height: 0.5, | |
| backgroundColor: '#E8E8E8', | |
| }, | |
| }); | 
现在,在此之后,您需要做的就是将MessageSlack和DateSeparator传递MessageList给App.js.
| import {DateSeparator} from './src/components/DateSeparator'; | |
| import {MessageSlack} from './src/components/MessageSlack'; | |
| function ChannelScreen({navigation, route}) { | |
| const [channel, setChannel] = useState(null); | |
| useEffect(() => { | |
| if (!channel) { | |
| navigation.openDrawer(); | |
| } | |
| const channelId = route.params ? route.params.channelId : null; | |
| const _channel = chatClient.channel('messaging', channelId); | |
| setChannel(_channel); | |
| }, [route.params]); | |
| return ( | |
| <SafeAreaView style={styles.channelScreenSaveAreaView}> | |
| <View style={styles.channelScreenContainer}> | |
| <ChannelHeader | |
| navigation={navigation} | |
| channel={channel} | |
| client={chatClient} | |
| /> | |
| <View style={styles.chatContainer}> | |
| <Chat client={chatClient}> | |
| <Channel channel={channel}> | |
| <MessageList | |
| Message={MessageSlack} | |
| DateSeparator={DateSeparator} | |
| /> | |
| <MessageInput /> | |
| </Channel> | |
| </Chat> | |
| </View> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| } | 
如果您刷新应用程序,您将看到 UI 现在与 Slack UI 有更好的一致性。
我们还需要添加一些最后的修饰,比如方形头像。头像应该与消息顶部对齐,并且消息不应该有边框,所以我们还需要做一些小的对齐调整。
我们将通过主题化聊天组件来解决这些问题。请阅读 Stream 的 react-native聊天教程中的自定义样式部分。
创建一个名为的文件src/stream-chat-theme.js:
| export default { | |
| 'messageList.dateSeparator.date': 'color: black;', | |
| 'messageInput.container': | |
| 'border-top-color: #979A9A; border-top-width: 0.4; background-color: white; margin: 0; border-radius: 0;', | |
| 'messageList.dateSeparator.container': 'margin-top: 10; margin-bottom: 5;', | |
| 'message.avatarWrapper.spacer': 'height: 0;', | |
| 'messageInput.sendButtonIcon': 'height: 20px; width: 20px;', | |
| 'messageInput.attachButtonIcon': 'height: 20px; width: 20px;', | |
| 'messageInput.inputBox': 'font-size: 15;', | |
| 'message.content.container': | |
| 'flex: 1; align-items: stretch; max-width: 320px; padding-top: 0; border-radius: 0;', | |
| 'message.content.textContainer': | |
| 'align-self: stretch; padding-top: 0;margin-top: 0;border-color: white;width: 100%', | |
| 'message.container': 'margin-bottom: 0; margin-top: 0', | |
| 'message.avatarWrapper.container': 'align-self: flex-start', | |
| 'avatar.image': 'border-radius: 5;', | |
| 'message.card.container': | |
| 'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8', | |
| 'message.gallery.single': | |
| 'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%', | |
| 'message.gallery.galleryContainer': | |
| 'border-top-left-radius: 8;border-top-right-radius: 8;border-bottom-left-radius: 8; border-bottom-right-radius: 8; margin-left: 5; width: 95%', | |
| 'message.replies.messageRepliesText': 'color: #0064c2', | |
| 'message.content.markdown': { | |
| text: { | |
| fontSize: 16, | |
| fontFamily: 'Lato-Regular', | |
| } | |
| }, | |
| }; | 
现在将此主题传递给App.js 中Chat的组件ChannelScreen,如下所示:
就这样!你应该会在屏幕上看到类似 Slack 的漂亮消息了。😺
输入框👨💻
现在让我们转到底部的输入框。该MessageInput组件(来自 Stream)接受Input一个自定义 UI 组件属性,用于显示在输入框中。让我们在 中创建这个自定义组件src/components/InputBox.js。
| import React from 'react'; | |
| import {TouchableOpacity, View, Text, StyleSheet} from 'react-native'; | |
| import { | |
| AutoCompleteInput, | |
| AttachButton, | |
| SendButton, | |
| } from 'stream-chat-react-native'; | |
| export const InputBox = props => { | |
| return ( | |
| <View style={styles.container}> | |
| <AutoCompleteInput {...props} /> | |
| <View style={styles.actionsContainer}> | |
| <View style={styles.row}> | |
| <TouchableOpacity | |
| onPress={() => { | |
| props.appendText('@'); | |
| }}> | |
| <Text style={styles.textActionLabel}>@</Text> | |
| </TouchableOpacity> | |
| {/* Text editor is not functional yet. We will cover it in some future tutorials */} | |
| <TouchableOpacity style={styles.textEditorContainer}> | |
| <Text style={styles.textActionLabel}>Aa</Text> | |
| </TouchableOpacity> | |
| </View> | |
| <View style={styles.row}> | |
| <AttachButton {...props} /> | |
| <SendButton {...props} /> | |
| </View> | |
| </View> | |
| </View> | |
| ); | |
| }; | |
| const styles = StyleSheet.create({ | |
| container: { | |
| flexDirection: 'column', | |
| flex: 1, | |
| height: 60, | |
| }, | |
| actionsContainer: {flexDirection: 'row', justifyContent: 'space-between'}, | |
| row: {flexDirection: 'row'}, | |
| textActionLabel: { | |
| color: '#787878', | |
| fontSize: 18, | |
| }, | |
| textEditorContainer: { | |
| marginLeft: 10, | |
| }, | |
| }); | 
我们在 InputBox 中使用的以下组件由 Stream 的 react-native SDK 提供,它为我们处理了很多事情:
- AutoCompleteInput- 负责所有输入框功能,例如提及、发送消息、维护启用/禁用状态等。
- SendButton
- AttachButton
我们所做的只是对 的内部组件进行改组MessageInput。
这里需要注意的是,必须将整个 prop 对象传递给AutoCompleteInput、SendButton和AttachButton。因此, 中的所有处理程序MessageInput都可以被这些组件访问。
现在将此InputBox组件传递给的MessageInput组件。ChannelScreenApp.js
该组件的最终版本ChannelScreen如下:
| function ChannelScreen({navigation, route}) { | |
| const [channel, setChannel] = useState(null); | |
| useEffect(() => { | |
| if (!channel) { | |
| navigation.openDrawer(); | |
| } | |
| const channelId = route.params ? route.params.channelId : null; | |
| const _channel = chatClient.channel('messaging', channelId); | |
| setChannel(_channel); | |
| }, [route.params]); | |
| return ( | |
| <SafeAreaView style={styles.channelScreenSaveAreaView}> | |
| <View style={styles.channelScreenContainer}> | |
| <ChannelHeader | |
| navigation={navigation} | |
| channel={channel} | |
| client={chatClient} | |
| /> | |
| <View style={styles.chatContainer}> | |
| <Chat client={chatClient} style={streamChatTheme}> | |
| <Channel channel={channel}> | |
| <MessageList | |
| Message={MessageSlack} | |
| DateSeparator={DateSeparator} | |
| /> | |
| <MessageInput | |
| Input={InputBox} | |
| additionalTextInputProps={{ | |
| placeholderTextColor: '#979A9A', | |
| placeholder: | |
| channel && channel.data.name | |
| ? 'Message #' + | |
| channel.data.name.toLowerCase().replace(' ', '_') | |
| : 'Message', | |
| }} | |
| /> | |
| </Channel> | |
| </Chat> | |
| </View> | |
| </View> | |
| </SafeAreaView> | |
| ); | |
| } | 
注意:MessageInput 组件额外增加了一个 prop -
additionalTextInputProps,用于修改输入框的占位符。
恭喜!👏
这篇教程的第一部分就到这里,关于如何使用 Stream 的 React Native 聊天组件构建一个 Slack 克隆版本。希望本教程对您有所帮助,并期待您的反馈。
在本教程的下一部分(稍后发布)中,我们将介绍其他 UI 组件及其功能,例如:
- 线程
- 频道搜索
- 操作表
- 未读消息通知
- 还有更多!
编码愉快!
文章来源:https://dev.to/vishalnarkhede/tutorial-how-to-build-a-slack-clone-with-react-native-part-1-37kn 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com
          









