ReactNativeでMapとCarouselのスワイプに連動するアニメーションの実現方法
今、ある案件のコンサルティングでReactNative GoogleMapとCarousel連動するアニメーションの実現する必要が出ています。
今日はその方法を解説していきたいと思います。
すみません、ビデオをちゃんと撮ると思って、数分前のツイートを消しました。
出来ました!Carouselとgoogle maps makerのアニメーション連動! #ReactNative pic.twitter.com/Lo0ZAwsZQA— 川島@ソフトウェア、Web、アプリ、IoT作るのが大好き (@kokensha_tech) 2018年8月5日
シミュレータからとってみました! pic.twitter.com/AALIdGZx33
— 川島@ソフトウェア、Web、アプリ、IoT作るのが大好き (@kokensha_tech) 2018年8月5日
#ReactNative 今度は、実機で実際に動かしてみましたが
動作はサクサクでとてもレスポンシブですが
なぜか、カードの間がシミュレーターと違って、密集していますね!
シミュレーターと実機のこの差異は何だろうな react nativeの問題なのか、シミュレーターの問題なのか、今断定出来ませんが。。。 pic.twitter.com/HasLXD7t4y— 川島@ソフトウェア、Web、アプリ、IoT作るのが大好き (@kokensha_tech) 2018年8月5日
目次
プログラムの修正
今回はこちらのソースコードを少し改造した
https://github.com/browniefed/map_animated_scrollview
その上、下記のライブラリーを使用することにします。
https://github.com/archriss/react-native-snap-carousel
修正まえのプログラム(Before)
import React, { Component } from "react"; import { AppRegistry, StyleSheet, Text, View, ScrollView, Animated, Image, Dimensions, TouchableOpacity, } from "react-native"; import MapView from "react-native-maps"; const Images = [ { uri: "https://i.imgur.com/sNam9iJ.jpg" }, { uri: "https://i.imgur.com/N7rlQYt.jpg" }, { uri: "https://i.imgur.com/UDrH0wm.jpg" }, { uri: "https://i.imgur.com/Ka8kNST.jpg" } ] const { width, height } = Dimensions.get("window"); const CARD_HEIGHT = height / 4; const CARD_WIDTH = CARD_HEIGHT - 50; export default class screens extends Component { state = { markers: [ { coordinate: { latitude: 45.524548, longitude: -122.6749817, }, title: "Best Place", description: "This is the best place in Portland", image: Images[0], }, { coordinate: { latitude: 45.524698, longitude: -122.6655507, }, title: "Second Best Place", description: "This is the second best place in Portland", image: Images[1], }, { coordinate: { latitude: 45.5230786, longitude: -122.6701034, }, title: "Third Best Place", description: "This is the third best place in Portland", image: Images[2], }, { coordinate: { latitude: 45.521016, longitude: -122.6561917, }, title: "Fourth Best Place", description: "This is the fourth best place in Portland", image: Images[3], }, ], region: { latitude: 45.52220671242907, longitude: -122.6653281029795, latitudeDelta: 0.04864195044303443, longitudeDelta: 0.040142817690068, }, }; componentWillMount() { this.index = 0; this.animation = new Animated.Value(0); } componentDidMount() { // We should detect when scrolling has stopped then animate // We should just debounce the event listener here this.animation.addListener(({ value }) => { let index = Math.floor(value / CARD_WIDTH + 0.3); // animate 30% away from landing on the next item if (index >= this.state.markers.length) { index = this.state.markers.length - 1; } if (index <= 0) { index = 0; } clearTimeout(this.regionTimeout); this.regionTimeout = setTimeout(() => { if (this.index !== index) { this.index = index; const { coordinate } = this.state.markers[index]; this.map.animateToRegion( { ...coordinate, latitudeDelta: this.state.region.latitudeDelta, longitudeDelta: this.state.region.longitudeDelta, }, 350 ); } }, 10); }); } render() { const interpolations = this.state.markers.map((marker, index) => { const inputRange = [ (index - 1) * CARD_WIDTH, index * CARD_WIDTH, ((index + 1) * CARD_WIDTH), ]; const scale = this.animation.interpolate({ inputRange, outputRange: [1, 2.5, 1], extrapolate: "clamp", }); const opacity = this.animation.interpolate({ inputRange, outputRange: [0.35, 1, 0.35], extrapolate: "clamp", }); return { scale, opacity }; }); return ( <View style={styles.container}> <MapView ref={map => this.map = map} initialRegion={this.state.region} style={styles.container} > {this.state.markers.map((marker, index) => { const scaleStyle = { transform: [ { scale: interpolations[index].scale, }, ], }; const opacityStyle = { opacity: interpolations[index].opacity, }; return ( <MapView.Marker key={index} coordinate={marker.coordinate}> <Animated.View style={[styles.markerWrap, opacityStyle]}> <Animated.View style={[styles.ring, scaleStyle]} /> <View style={styles.marker} /> </Animated.View> </MapView.Marker> ); })} </MapView> <Animated.ScrollView horizontal scrollEventThrottle={1} showsHorizontalScrollIndicator={false} snapToInterval={CARD_WIDTH} onScroll={Animated.event( [ { nativeEvent: { contentOffset: { x: this.animation, }, }, }, ], { useNativeDriver: true } )} style={styles.scrollView} contentContainerStyle={styles.endPadding} > {this.state.markers.map((marker, index) => ( <View style={styles.card} key={index}> <Image source={marker.image} style={styles.cardImage} resizeMode="cover" /> <View style={styles.textContent}> <Text numberOfLines={1} style={styles.cardtitle}>{marker.title}</Text> <Text numberOfLines={1} style={styles.cardDescription}> {marker.description} </Text> </View> </View> ))} </Animated.ScrollView> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, }, scrollView: { position: "absolute", bottom: 30, left: 0, right: 0, paddingVertical: 10, }, endPadding: { paddingRight: width - CARD_WIDTH, }, card: { padding: 10, elevation: 2, backgroundColor: "#FFF", marginHorizontal: 10, shadowColor: "#000", shadowRadius: 5, shadowOpacity: 0.3, shadowOffset: { x: 2, y: -2 }, height: CARD_HEIGHT, width: CARD_WIDTH, overflow: "hidden", }, cardImage: { flex: 3, width: "100%", height: "100%", alignSelf: "center", }, textContent: { flex: 1, }, cardtitle: { fontSize: 12, marginTop: 5, fontWeight: "bold", }, cardDescription: { fontSize: 12, color: "#444", }, markerWrap: { alignItems: "center", justifyContent: "center", }, marker: { width: 8, height: 8, borderRadius: 4, backgroundColor: "rgba(130,4,150, 0.9)", }, ring: { width: 24, height: 24, borderRadius: 12, backgroundColor: "rgba(130,4,150, 0.3)", position: "absolute", borderWidth: 1, borderColor: "rgba(130,4,150, 0.5)", }, }); AppRegistry.registerComponent("mapfocus", () => screens);
修正後のプログラム(After)
こんなイメージで実現したいですね。
こちらとなります!
Imageは、https://github.com/browniefed/map_animated_scrollview のものそのまま利用します。
import React, { Component } from "react"; import { AppRegistry, StyleSheet, Text, View, ScrollView, Animated, Image, Dimensions, TouchableOpacity, } from "react-native"; import Carousel from 'react-native-snap-carousel'; import MapView from "react-native-maps"; const Images = [ { uri: "https://i.imgur.com/sNam9iJ.jpg" }, { uri: "https://i.imgur.com/N7rlQYt.jpg" }, { uri: "https://i.imgur.com/UDrH0wm.jpg" }, { uri: "https://i.imgur.com/Ka8kNST.jpg" } ] const { width, height } = Dimensions.get("window"); const CARD_HEIGHT = height / 4; const CARD_WIDTH = CARD_HEIGHT - 50; export default class screens extends Component { state = { scrolledX: 0, markers: [ { coordinate: { latitude: 45.524548, longitude: -122.6749817, }, title: "Best Place", description: "This is the best place in Portland", image: Images[0], }, { coordinate: { latitude: 45.524698, longitude: -122.6655507, }, title: "Second Best Place", description: "This is the second best place in Portland", image: Images[1], }, { coordinate: { latitude: 45.5230786, longitude: -122.6701034, }, title: "Third Best Place", description: "This is the third best place in Portland", image: Images[2], }, { coordinate: { latitude: 45.521016, longitude: -122.6561917, }, title: "Fourth Best Place", description: "This is the fourth best place in Portland", image: Images[3], }, ], region: { latitude: 45.52220671242907, longitude: -122.6653281029795, latitudeDelta: 0.04864195044303443, longitudeDelta: 0.040142817690068, }, }; componentWillMount() { this.index = 0; this.animation = new Animated.Value(0); } componentDidMount() { // We should detect when scrolling has stopped then animate // We should just debounce the event listener here this.animation.addListener(({ value }) => { let index = Math.floor(value / CARD_WIDTH + 0.3); // animate 30% away from landing on the next item if(index >= this.state.markers.length) { index = this.state.markers.length - 1; } if(index <= 0) { index = 0; } clearTimeout(this.regionTimeout); this.regionTimeout = setTimeout(() => { if(this.index !== index) { this.index = index; const { coordinate } = this.state.markers[index]; this.map.animateToRegion({ ...coordinate, latitudeDelta: this.state.region.latitudeDelta, longitudeDelta: this.state.region.longitudeDelta, }, 350); } }, 10); }); } _renderItem({ item, index }) { return(<View style={styles.card} key={index}> <Image source={item.image} style={styles.cardImage} resizeMode="cover" /> <View style={styles.textContent}> <Text numberOfLines={1} style={styles.cardtitle}>{item.title}</Text> <Text numberOfLines={1} style={styles.cardDescription}> {item.description} </Text> </View> </View>); } render() { const interpolations = this.state.markers.map((marker, index) => { const inputRange = [ (index - 1) * CARD_WIDTH, index * CARD_WIDTH, ((index + 1) * CARD_WIDTH), ]; const scale = this.animation.interpolate({ inputRange, outputRange: [1, 2.5, 1], extrapolate: "clamp", }); const opacity = this.animation.interpolate({ inputRange, outputRange: [0.35, 1, 0.35], extrapolate: "clamp", }); return { scale, opacity }; }); return(<View style={styles.container}> <MapView ref={map => this.map = map} initialRegion={this.state.region} style={styles.container} > {this.state.markers.map((marker, index) => { const scaleStyle = { transform: [ { scale: interpolations[index].scale, }, ], }; const opacityStyle = { opacity: interpolations[index].opacity, }; return ( <MapView.Marker key={index} coordinate={marker.coordinate}> <Animated.View style={[styles.markerWrap, opacityStyle]}> <Animated.View style={[styles.ring, scaleStyle]} /> <View style={styles.marker} /> </Animated.View> </MapView.Marker> ); })} </MapView> <Animated.View style={styles.scrollView} contentContainerStyle={styles.endPadding} > <Carousel style={styles.carouselStyle} ref={(c) => { this._carousel = c; }} data={this.state.markers} renderItem={this._renderItem} sliderWidth={width} itemWidth={120} onScroll={(event)=>{ this.animation.setValue(event.nativeEvent.contentOffset.x); }} useScrollView={true} /> </Animated.View> </View>); } } const styles = StyleSheet.create({ container: { flex: 1, }, scrollView: { position: "absolute", bottom: 20, left: 0, right: 0, paddingVertical: 10, }, carouselStyle: { padding: 25, }, endPadding: { paddingRight: width - CARD_WIDTH, }, card: { padding: 10, elevation: 2, backgroundColor: "rgba(244,255,244, 1)", marginHorizontal: 10, margin: 30, shadowColor: "rgba(0,72,51, 0.9)", shadowRadius: 5, shadowOpacity: 0.3, shadowOffset: { x: 0, y: 0 }, height: CARD_HEIGHT, width: CARD_WIDTH, }, cardImage: { flex: 3, width: "100%", height: "100%", alignSelf: "center", }, textContent: { flex: 1, }, cardtitle: { fontSize: 12, marginTop: 5, fontWeight: "bold", }, cardDescription: { fontSize: 12, color: "#444", }, markerWrap: { alignItems: "center", justifyContent: "center", }, marker: { width: 8, height: 8, borderRadius: 4, backgroundColor: "rgba(0,153,102, 0.9)", }, ring: { width: 24, height: 24, borderRadius: 12, backgroundColor: "rgba(0,153,102, 0.5)", position: "absolute", borderWidth: 0.5, borderColor: "rgba(0,153,102, 0.5)", }, }); AppRegistry.registerComponent("mapfocus", () => screens);
今回一番重要なのはこの部分ですね。
<Animated.View style={styles.scrollView} contentContainerStyle={styles.endPadding} > <Carousel style={styles.carouselStyle} ref={(c) => { this._carousel = c; }} data={this.state.markers} renderItem={this._renderItem} sliderWidth={width} itemWidth={120} onScroll={(event)=>{ this.animation.setValue(event.nativeEvent.contentOffset.x); }} useScrollView={true} /> </Animated.View>
さらにポイントになっているのは
onScroll={(event)=>{ this.animation.setValue(event.nativeEvent.contentOffset.x); }}
EXPO
expoでゼロから作り直した!
こんな感じになりました! pic.twitter.com/n84CFG6MRB
— 機械学習・深層学習・Python・Web、IoT (@kokensha_tech) 2019年3月9日
結構楽しいです! pic.twitter.com/Js90Fy5T6S
— 機械学習・深層学習・Python・Web、IoT (@kokensha_tech) 2019年3月9日
ソースコードをGitHubに公開しました!https://t.co/n8jng8H432
— 機械学習・深層学習・Python・Web、IoT (@kokensha_tech) 2019年3月9日
念の為、もう一回GitHubのurlを書きます。
https://github.com/kawashimaken/map_with_snap_carousel
自由にフォークして改造してください。
まとめ
いかがですか?それっぽくなっていますね。
もちろん、これは完成形ではなく、もっと色々チューニングする必要があります。
また、サーバーのデータを動的に連動して、場所の情報を取ってきて表示したりすることも可能です。
ご不明点等がありましたら、TwitterでもDMをください。
では Happy Coding!
#ReactNative で実はこんなの作りかけていました!
#iOS #iosdev #AndroidDev #Android
ITイベント複数サービス横断検索アプリ!!!
RTが500を超えたら、これを完成させてリリースする!!!
これが欲しいかも、これを使ってみたい
あるいは、これの作り方を知りたい方は
RTをお願いします! pic.twitter.com/fKPVk74ofq— 川島@ソフトウェア、Web、アプリ、IoT作るのが大好き (@kokensha_tech) 2018年6月20日
[amazonjs asin=”B078KDYXSG” locale=”JP” title=”React Nativeで初めるiOS・Androidクロスプラットフォームアプリ開発入門 – その1”]