From b117932d85ecbc2bd88332646925551c4a95d0c3 Mon Sep 17 00:00:00 2001 From: Ananta Pandey Date: Mon, 27 Aug 2018 18:46:17 -0400 Subject: [PATCH] UI Tweaks (#34) * center map around park instead of user * ui tweaks * make drawer draggy --- expo_project/components/ColoredButton.js | 15 +- expo_project/components/MapWithMarkers.js | 72 +++--- expo_project/components/Selectable.js | 34 ++- expo_project/components/Survey.js | 3 +- expo_project/config/questions.js | 48 +--- expo_project/constants/Layout.js | 18 +- expo_project/constants/Map.js | 8 +- expo_project/screens/HomeScreen.js | 281 ++++++++++++---------- 8 files changed, 260 insertions(+), 219 deletions(-) diff --git a/expo_project/components/ColoredButton.js b/expo_project/components/ColoredButton.js index ee46401..767bffc 100644 --- a/expo_project/components/ColoredButton.js +++ b/expo_project/components/ColoredButton.js @@ -5,10 +5,10 @@ import { StyleSheet, Text, TouchableOpacity } from "react-native"; class ColoredButton extends React.Component { render() { - const { backgroundColor, color, onPress, label } = this.props; + const { style, backgroundColor, color, onPress, label } = this.props; return ( {label} @@ -19,7 +19,6 @@ class ColoredButton extends React.Component { const styles = StyleSheet.create({ button: { - backgroundColor: "#5B93D9", padding: 12, marginVertical: 20, justifyContent: "center", @@ -29,7 +28,15 @@ const styles = StyleSheet.create({ }); ColoredButton.propTypes = { - color: PropTypes.string.isRequired + style: PropTypes.object, + color: PropTypes.string, + backgroundColor: PropTypes.string +}; + +ColoredButton.defaultProps = { + style: {}, + backgroundColor: "blue", + color: "#5B93D9" }; export default ColoredButton; diff --git a/expo_project/components/MapWithMarkers.js b/expo_project/components/MapWithMarkers.js index 4e53e66..c1d7fdf 100644 --- a/expo_project/components/MapWithMarkers.js +++ b/expo_project/components/MapWithMarkers.js @@ -1,9 +1,9 @@ import PropTypes from "prop-types"; import React from "react"; -import { StyleSheet } from "react-native"; +import { Platform, StyleSheet } from "react-native"; import { MapView } from "expo"; import PersonIcon from "./PersonIcon"; -import { Location, Permissions } from "expo"; +// import { Location, Permissions } from "expo"; import MapConfig from "../constants/Map"; @@ -11,34 +11,32 @@ class MapWithMarkers extends React.Component { constructor(props) { super(props); - this.state = { - initialRegion: null - }; + this.state = { region: MapConfig.defaultRegion }; } - componentDidMount() { - this._getLocationAsync(); - } + // componentDidMount() { + // this._getLocationAsync(); + // } - _getLocationAsync = async () => { - let region = MapConfig.defaultRegion; - // react native maps (the belly of expo's MapView ) requests location permissions for us - // so here we are only retrieving permission, not asking for it - const { status } = await Permissions.getAsync(Permissions.LOCATION); - if (status === "granted") { - const location = await Location.getCurrentPositionAsync({ - enableHighAccuracy: true - }); - const { latitude, longitude } = location.coords; - region = { - latitude, - longitude, - latitudeDelta: 0.0043, - longitudeDelta: 0.0034 - }; - } - this.setState({ region }); - }; + // _getLocationAsync = async () => { + // let region = MapConfig.defaultRegion; + // // react native maps (the belly of expo's MapView ) requests location permissions for us + // // so here we are only retrieving permission, not asking for it + // const { status } = await Permissions.askAsync(Permissions.LOCATION); + // if (status === "granted") { + // const location = await Location.getCurrentPositionAsync({ + // enableHighAccuracy: true + // }); + // const { latitude, longitude } = location.coords; + // region = { + // latitude, + // longitude, + // latitudeDelta: 0.0043, + // longitudeDelta: 0.0034 + // }; + // } + // this.setState({ region }); + // }; render() { const { @@ -58,14 +56,11 @@ class MapWithMarkers extends React.Component { showsUserLocation scrollEnabled zoomEnabled - rotateEnabled - showsCompass pitchEnabled={false} > {markers.map(marker => { const selected = marker.id === activeMarkerId; - // Update the key when selected or delected, so the marker re renders and centers itself based on the new child size - const key = marker.id + (selected ? "-selected" : ""); + const key = marker.id; return ( {title} - - {_.map(options, option => { + + {_.map(options, (option, index) => { const { value, label } = option; const selected = value === selectedValue; return ( { onSelectablePress(value, this.state.height); }} @@ -74,20 +88,22 @@ const styles = StyleSheet.create({ marginRight: 5, marginTop: 10 }, - selected: { - backgroundColor: colors.colorSecondary + firstCell: { + marginLeft: 20 }, pillText: { fontFamily: "monaco" }, title: { - marginBottom: 5 + marginBottom: 5, + paddingHorizontal: 20 } }); Selectable.propTypes = { onSelectablePress: PropTypes.func.isRequired, selectedValue: PropTypes.string, + selectedColor: PropTypes.string, title: PropTypes.string.isRequired, options: PropTypes.arrayOf( PropTypes.shape({ @@ -97,4 +113,8 @@ Selectable.propTypes = { ).isRequired }; +Selectable.defaultProps = { + selectedColor: colors.colorSecondary +}; + export default Selectable; diff --git a/expo_project/components/Survey.js b/expo_project/components/Survey.js index a4c4597..623f70b 100644 --- a/expo_project/components/Survey.js +++ b/expo_project/components/Survey.js @@ -24,6 +24,7 @@ class Survey extends React.Component { onSelect(activeMarker.id, questionKey, value, selectableHeight) } selectedValue={activeMarker[questionKey]} + selectedColor={activeMarker.color} title={questionLabel} options={options} /> @@ -35,7 +36,7 @@ class Survey extends React.Component { } const styles = StyleSheet.create({ - titleContainer: { paddingVertical: 10 }, + titleContainer: { paddingVertical: 10, paddingHorizontal: 20 }, title: { fontWeight: "bold" } }); diff --git a/expo_project/config/questions.js b/expo_project/config/questions.js index e3cf049..426f7b7 100644 --- a/expo_project/config/questions.js +++ b/expo_project/config/questions.js @@ -30,9 +30,9 @@ export default [ questionKey: "groupSize", questionLabel: "Group Size", options: [ - { value: "alone", label: "1" }, - { value: "pair", label: "2" }, - { value: "group", label: "3+" } + { value: "pair", label: "pair" }, + { value: "group", label: "group" }, + { value: "crowd", label: "crowd" } ] }, { @@ -67,47 +67,5 @@ export default [ { value: "pushcart", label: "Push Cart" }, { value: "stroller", label: "Stroller" } ] - }, - { - questionKey: "blahgender", - questionLabel: "Fake Question (for demo purposes)", - options: [ - { value: "male", label: "1" }, - { value: "female", label: "2" }, - { value: "unknown", label: "3" } - ] - }, - { - questionKey: "genderblah", - questionLabel: "Fake Question (for demo purposes)", - options: [ - { value: "male", label: "4" }, - { value: "female", label: "5" }, - { value: "unknown", label: "6" }, - { value: "male1", label: "7" }, - { value: "female2", label: "8" }, - { value: "unknown3", label: "9" }, - { value: "male4", label: "10" }, - { value: "female5", label: "11" }, - { value: "unknown6", label: "12" } - ] - }, - { - questionKey: "adfsasdfgender", - questionLabel: "Fake Question (for demo purposes)", - options: [ - { value: "male", label: "fakefakeffake" }, - { value: "female", label: "reallylongword" }, - { value: "unknown", label: "thisshouldmakeyouscroll" } - ] - }, - { - questionKey: "genafdsafdsder", - questionLabel: "Fake Question (for demo purposes)", - options: [ - { value: "male", label: "100" }, - { value: "female", label: "99" }, - { value: "unknown", label: "98" } - ] } ]; diff --git a/expo_project/constants/Layout.js b/expo_project/constants/Layout.js index 1a15a93..41ae010 100644 --- a/expo_project/constants/Layout.js +++ b/expo_project/constants/Layout.js @@ -1,12 +1,20 @@ -import { Dimensions } from 'react-native'; +import { Dimensions } from "react-native"; +import { Header } from "react-navigation"; -const width = Dimensions.get('window').width; -const height = Dimensions.get('window').height; +const width = Dimensions.get("window").width; +const height = Dimensions.get("window").height; export default { window: { width, - height, + height }, - isSmallDevice: width < 375, + header: { + height: Header.HEIGHT + }, + drawer: { + height: height - Header.HEIGHT, + width + }, + isSmallDevice: width < 375 }; diff --git a/expo_project/constants/Map.js b/expo_project/constants/Map.js index 066e1d3..006e734 100644 --- a/expo_project/constants/Map.js +++ b/expo_project/constants/Map.js @@ -1,8 +1,8 @@ export default { defaultRegion: { - latitude: 0, - longitude: 0, - latitudeDelta: 0, - longitudeDelta: 0 + latitude: 43.703805, + longitude: -79.343568, + latitudeDelta: 0.0043, + longitudeDelta: 0.0034 } }; diff --git a/expo_project/screens/HomeScreen.js b/expo_project/screens/HomeScreen.js index c4669c3..cf9f0ea 100644 --- a/expo_project/screens/HomeScreen.js +++ b/expo_project/screens/HomeScreen.js @@ -1,42 +1,39 @@ import React from "react"; import { - Dimensions, PanResponder, Platform, StyleSheet, View, - Animated + Animated, + TouchableOpacity } from "react-native"; import { withNavigation } from "react-navigation"; -import { firestore } from 'firebase'; import * as _ from "lodash"; -import Colors, { iconColors } from "../constants/Colors"; +import { iconColors } from "../constants/Colors"; import { ScrollView } from "../node_modules/react-native-gesture-handler"; -const { height } = Dimensions.get("window"); import moment from "moment"; -import { Header } from "react-navigation"; +import Layout from "../constants/Layout"; import MapWithMarkers from "../components/MapWithMarkers"; import MarkerCarousel from "../components/MarkerCarousel"; import Survey from "../components/Survey"; import ColoredButton from "../components/ColoredButton"; -const HEADER_HEIGHT = Header.HEIGHT; -const MIN_DRAWER_OFFSET = 0; // fix this - -const DRAWER_HEIGHT = height - HEADER_HEIGHT; -const INITIAL_DRAWER_OFFSET = DRAWER_HEIGHT; - -const studyId = '50Kb9Jfa1ejkURIIE3T2'; // todo should be dynamically set -const surveyId = 'UaAyBbLNOobGO2prwpsT'; // todo should be dynamically set +// TODO (Ananta): shouold be dynamically set +const INITIAL_DRAWER_TRANSLATE_Y = Layout.drawer.height; +const MIN_DRAWER_TRANSLATE_Y = 0; +const MID_DRAWER_TRANSLATE_Y = Layout.drawer.height - 300; +const MAX_DRAWER_TRANSLATE_Y = Layout.drawer.height - 95; // mostly collapsed, with just the header peaking out +const studyId = "50Kb9Jfa1ejkURIIE3T2"; // todo should be dynamically set +const surveyId = "UaAyBbLNOobGO2prwpsT"; // todo should be dynamically set function _markerToDataPoint(marker) { - const dataPoint = {} - fields = [ 'gender', 'groupSize', 'mode', 'object', 'posture', 'timestamp' ]; - fields.forEach((field) => { + const dataPoint = {}; + fields = ["gender", "groupSize", "mode", "object", "posture", "timestamp"]; + fields.forEach(field => { if (marker[field]) { - dataPoint[field] = marker[field] + dataPoint[field] = marker[field]; } }); @@ -46,6 +43,16 @@ function _markerToDataPoint(marker) { return dataPoint; } +class Indicator extends React.Component { + render() { + return ( + + + + ); + } +} + class HomeScreen extends React.Component { static navigationOptions = { title: "Long press map to add a pin" @@ -54,8 +61,6 @@ class HomeScreen extends React.Component { constructor(props) { super(props); - this.drawerOffsetY = new Animated.Value(INITIAL_DRAWER_OFFSET); - this.drawerOffsetY.addListener(({ value }) => (this._value = value)); // firestore has its own timestamp type this.firestore = this.props.screenProps.firebase.firestore(); this.firestore.settings({ timestampsInSnapshots: true }); @@ -64,99 +69,110 @@ class HomeScreen extends React.Component { activeMarkerId: null, markers: [], formScrollPosition: 0, - drawerHeaderHeight: 0 + pan: new Animated.ValueXY({ x: 0, y: INITIAL_DRAWER_TRANSLATE_Y }) }; + this._drawerY = INITIAL_DRAWER_TRANSLATE_Y; + this.state.pan.addListener(value => (this._drawerY = value.y)); + this.resetDrawer = this.resetDrawer.bind(this); + this.toggleDrawer = this.toggleDrawer.bind(this); this.selectMarker = this.selectMarker.bind(this); this.getRandomIconColor = this.getRandomIconColor.bind(this); this.createNewMarker = this.createNewMarker.bind(this); this.setMarkerLocation = this.setMarkerLocation.bind(this); this.setFormResponse = this.setFormResponse.bind(this); + } - // TODO (Ananta): Make this easier to understand - // TODO (Ananta): use a "top" value instead of an offset from top, so + / - is a consistent direction between pan responder and scrollview + componentWillMount() { this._panResponder = PanResponder.create({ - // Ask to be the responder: onStartShouldSetPanResponder: (evt, gestureState) => false, onStartShouldSetPanResponderCapture: (evt, gestureState) => false, - onMoveShouldSetPanResponder: (evt, gestureState) => { - // Respond to downward drags if they are a long distance or the scrollview is at the top - // Upward drags are handled in onMoveShouldSetPanResponderCapture because they override all child gestures - - const verticalDistance = Math.abs(gestureState.dy); - const horizontalDistance = Math.abs(gestureState.dx); - const isVerticalPan = verticalDistance > horizontalDistance; - - if (isVerticalPan) { - // only pan if it's a long distance or you can't scroll any more - const directionDown = gestureState.dy > 0; - const scrolledToTop = !this.state.formScrollPosition; - const isLongDistance = gestureState.dy > 50; - return directionDown && (scrolledToTop || isLongDistance); - } - return false; - }, onMoveShouldSetPanResponderCapture: (evt, gestureState) => { - // Respond and capture (disallow children from responding) if panning upward - // Returning true here will skip onMoveShouldSetPanResponder - + // This method captures gestures, (which means the scrollview will not scroll) + // For drags that should not block other gesture responses, use onMoveShouldSetPanResponder instead const verticalDistance = Math.abs(gestureState.dy); const horizontalDistance = Math.abs(gestureState.dx); const isVerticalPan = verticalDistance > horizontalDistance; if (isVerticalPan) { const directionUp = gestureState.dy < 0; - const hasSpaceToPanUp = this.drawerOffsetY._value > MIN_DRAWER_OFFSET; - return directionUp && hasSpaceToPanUp; + if (directionUp) { + // Respond to upward drags so long as there is room to expand the drawer + const hasSpaceToPanUp = this._drawerY > MIN_DRAWER_TRANSLATE_Y; + return hasSpaceToPanUp; + } else { + // Respond to downward drags if they are a long distance / high velocity or the scrollview is at the top + const hasSpaceToPanDown = this._drawerY <= MAX_DRAWER_TRANSLATE_Y; + const isScrolledToTop = !this.state.formScrollPosition; + const isHeavyScroll = + Math.abs(gestureState.dy) > 80 || Math.abs(gestureState.vy) > 1.2; + return hasSpaceToPanDown && (isScrolledToTop || isHeavyScroll); + } } return false; }, - onPanResponderMove: (evt, gestureState) => { - const directionDown = gestureState.dy > 0; - const currentDrawerOffset = this.drawerOffsetY._value; - - // TODO: Make the drawer follow user's gesture - const canMoveDown = - directionDown && - currentDrawerOffset < - INITIAL_DRAWER_OFFSET - this.state.drawerHeaderHeight; - const canMoveUp = - !directionDown && currentDrawerOffset > MIN_DRAWER_OFFSET; - - if (canMoveUp || canMoveDown) { - const toValue = canMoveUp - ? MIN_DRAWER_OFFSET - : INITIAL_DRAWER_OFFSET - this.state.drawerHeaderHeight; - Animated.spring(this.drawerOffsetY, { - toValue, - useNativeDriver: true - }).start(); - if (canMoveDown && this.state.formScrollPosition) { - this.scrollView.scrollTo({ - x: 0, - y: 0, - animated: false - }); - } + // set store current value as offset, and set value to 0, + // since onPanResponderMove converts delta offset into value and starts from 0 on every new gesture + onPanResponderGrant: (evt, gestureState) => { + this.state.pan.setOffset({ x: 0, y: this.state.pan.y._value }); + this.state.pan.setValue({ x: 0, y: 0 }); + }, + // Follow the gesture + onPanResponderMove: Animated.event([null, { dy: this.state.pan.y }]), + + // Snap to a breakpoint when the gesture is release + onPanResponderRelease: (evt, gestureState) => { + // look at velocity on release, instead of direction + // If user wiggles back and forth, we want to snap in the direction of terminal velocity + const directionUp = gestureState.vy < 0; + this.state.pan.flattenOffset(); + const y = directionUp ? MIN_DRAWER_TRANSLATE_Y : MAX_DRAWER_TRANSLATE_Y; + + Animated.spring(this.state.pan, { + toValue: { + x: 0, + y + }, + useNativeDriver: true, + friction: 5 + }).start(); + + if (this.state.formScrollPosition) { + this.scrollView.scrollTo({ + y: 0, + animated: false + }); } - return true; } }); } - resetDrawer() { - const isEmpty = this.state.markers.length === 0; - const offsetVal = isEmpty ? INITIAL_DRAWER_OFFSET : DRAWER_HEIGHT - 250; //fix this - if (this.drawerOffsetY._value !== offsetVal) { - Animated.timing(this.drawerOffsetY, { - toValue: offsetVal, + toggleDrawer() { + const y = + this._drawerY === MIN_DRAWER_TRANSLATE_Y + ? MAX_DRAWER_TRANSLATE_Y + : MIN_DRAWER_TRANSLATE_Y; + Animated.timing(this.state.pan, { + toValue: { x: 0, y }, + duration: 200, + useNativeDriver: true + }).start(); + + if (this.state.formScrollPosition) { + this.scrollView.scrollTo({ y: 0, animated: false }); + } + } + + resetDrawer(y = MID_DRAWER_TRANSLATE_Y) { + if (this._drawerY !== y) { + Animated.timing(this.state.pan, { + toValue: { x: 0, y }, duration: 200, useNativeDriver: true }).start(); } - if (this.state.formScrollPosition) { - this.scrollView.scrollTo({ x: 0, y: 0, animated: false }); + this.scrollView.scrollTo({ y: 0, animated: false }); } } @@ -172,27 +188,30 @@ class HomeScreen extends React.Component { markers: markersCopy }); this.firestore - .collection('study').doc(studyId) - .collection('survey').doc(surveyId) - .collection('dataPoints').doc(marker.id) + .collection("study") + .doc(studyId) + .collection("survey") + .doc(surveyId) + .collection("dataPoints") + .doc(marker.id) .set(_markerToDataPoint(marker)); const currentScrollPosition = this.state.formScrollPosition; - const currentDrawerOffset = this.drawerOffsetY._value; + const currentDrawerOffset = this._drawerY; const newDrawerOffset = currentDrawerOffset - selectableHeight; - if (newDrawerOffset >= MIN_DRAWER_OFFSET) { - Animated.timing(this.drawerOffsetY, { - toValue: newDrawerOffset, + if (newDrawerOffset >= MIN_DRAWER_TRANSLATE_Y) { + Animated.timing(this.state.pan, { + toValue: { x: 0, y: newDrawerOffset }, duration: 200, useNativeDriver: true }).start(); - } else if (currentDrawerOffset > MIN_DRAWER_OFFSET) { + } else if (currentDrawerOffset > MIN_DRAWER_TRANSLATE_Y) { // Animate drawer to the top // then scroll the remaining amount to ensure next question is visible - const remainder = currentDrawerOffset - MIN_DRAWER_OFFSET; - Animated.timing(this.drawerOffsetY, { - toValue: MIN_DRAWER_OFFSET, + const remainder = currentDrawerOffset - MIN_DRAWER_TRANSLATE_Y; + Animated.timing(this.state.pan, { + toValue: { x: 0, y: MIN_DRAWER_TRANSLATE_Y }, duration: 200, useNativeDriver: true }).start(); @@ -208,15 +227,18 @@ class HomeScreen extends React.Component { } selectMarker(activeMarkerId) { - this.setState({ activeMarkerId }); - this.resetDrawer(); + if (activeMarkerId === this.state.activeMarkerId) { + this.toggleDrawer(); + } else { + this.setState({ activeMarkerId }); + this.resetDrawer(MIN_DRAWER_TRANSLATE_Y); + } } createNewMarker(e) { const markersCopy = [...this.state.markers]; const date = moment(); const dateLabel = date.format("HH:mm"); - const timestamp = date.format("x"); const title = "Person " + (markersCopy.length + 1); const marker = { @@ -227,11 +249,13 @@ class HomeScreen extends React.Component { }; this.firestore - .collection('study').doc(studyId) - .collection('survey').doc(surveyId) - .collection('dataPoints') + .collection("study") + .doc(studyId) + .collection("survey") + .doc(surveyId) + .collection("dataPoints") .add(_markerToDataPoint(marker)) - .then((doc) => { + .then(doc => { const { id, timestamp } = doc; marker.id = id; marker.timestamp = timestamp; @@ -241,8 +265,7 @@ class HomeScreen extends React.Component { this.resetDrawer ); }); - - } + } setMarkerLocation(e) { // TODO: add logic for updating in db @@ -257,10 +280,13 @@ class HomeScreen extends React.Component { }); this.firestore - .collection('study').doc(studyId) - .collection('survey').doc(surveyId) - .collection('dataPoints').doc(marker.firestoreId) - .update({location: marker.coordinate}); + .collection("study") + .doc(studyId) + .collection("survey") + .doc(surveyId) + .collection("dataPoints") + .doc(marker.firestoreId) + .update({ location: marker.coordinate }); } } @@ -285,18 +311,12 @@ class HomeScreen extends React.Component { - - this.setState({ - drawerHeaderHeight: e.nativeEvent.layout.height - }) - } - > + + this.resetDrawer()} label="Done" /> )} + ); @@ -345,7 +369,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 16, borderTopRightRadius: 16, backgroundColor: "white", - height: DRAWER_HEIGHT, + height: Layout.drawer.height, ...Platform.select({ ios: { shadowColor: "black", @@ -359,12 +383,27 @@ const styles = StyleSheet.create({ }) }, drawerHeader: { - alignSelf: "stretch", - marginTop: 10 + alignSelf: "stretch" }, formContainer: { - paddingHorizontal: 20, alignSelf: "stretch" + }, + indicator: { + width: 30, + height: 5, + alignSelf: "center", + marginTop: 10, + backgroundColor: "#D8D8D8", + borderRadius: 10 + }, + bottomGuard: { + // This view adds whitespace below the drawer, in case the user over-pans it + position: "absolute", + left: 0, + right: 0, + bottom: -500, + height: 500, + backgroundColor: "white" } });