diff --git a/DataRepository.js b/DataRepository.js index 3ca1f9d..0a249af 100644 --- a/DataRepository.js +++ b/DataRepository.js @@ -7,7 +7,7 @@ var { } = React; var API_COVER_URL = "http://news-at.zhihu.com/api/4/start-image/1080*1776"; -var API_LATEST_URL = 'http://news.at.zhihu.com/api/4/news/latest'; +var API_LATEST_URL = 'http://news-at.zhihu.com/api/4/news/latest'; var API_HOME_URL = 'http://news.at.zhihu.com/api/4/news/before/'; var API_THEME_URL = 'http://news-at.zhihu.com/api/4/theme/'; var API_THEMES_URL = 'http://news-at.zhihu.com/api/4/themes'; @@ -16,6 +16,7 @@ var KEY_COVER = '@Cover'; var KEY_THEMES = '@Themes:'; var KEY_HOME_LIST = '@HomeList:'; var KEY_THEME_LIST = '@ThemeList:'; +var KEY_THEME_TOPDATA = '@ThemeTop:'; function parseDateFromYYYYMMdd(str) { if (!str) return new Date(); @@ -85,18 +86,24 @@ DataRepository.prototype.updateCover = function() { DataRepository.prototype.fetchStories = function(date?: Date, callback?: ?(error: ?Error, result: ?Object) => void ) { + var reqUrl; + var topData = null; if (!date) { date = new Date(); - }; + reqUrl = API_LATEST_URL; + topData = this._safeStorage(KEY_THEME_TOPDATA); + } else { + var beforeDate = new Date(date); + beforeDate.setDate(date.getDate() + 1); + reqUrl = API_HOME_URL + beforeDate.yyyymmdd(); + } var localStorage = this._safeStorage(KEY_HOME_LIST + date.yyyymmdd()); - var beforeDate = new Date(date); - beforeDate.setDate(date.getDate() + 1); - var networking = this._safeFetch(API_HOME_URL + beforeDate.yyyymmdd()); + var networking = this._safeFetch(reqUrl); var merged = new Promise((resolve, reject) => { - Promise.all([localStorage, networking]) + Promise.all([localStorage, networking, topData]) .then((values) => { var error, result; result = this._mergeReadState(values[0], values[1]); @@ -107,6 +114,11 @@ DataRepository.prototype.fetchStories = function(date?: Date, if (error) { reject(error); } else { + if (values[1] && values[1].top_stories) { + result.topData = values[1].top_stories; + } else { + result.topData = values[2]; + } resolve(result); } }); @@ -114,7 +126,7 @@ DataRepository.prototype.fetchStories = function(date?: Date, return merged; }; -DataRepository.prototype.fetchThemeStories = function(themeId: Number, lastID?: string, +DataRepository.prototype.fetchThemeStories = function(themeId: number, lastID?: string, callback?: ?(error: ?Error, result: ?Object) => void ) { // Home story list @@ -123,10 +135,7 @@ DataRepository.prototype.fetchThemeStories = function(themeId: Number, lastID?: if (lastID) { date = parseDateFromYYYYMMdd(lastID); date.setDate(date.getDate() - 1); - } else { - date = new Date(); } - return this.fetchStories(date, callback); } @@ -135,14 +144,17 @@ DataRepository.prototype.fetchThemeStories = function(themeId: Number, lastID?: var localStorage = isRefresh ? this._safeStorage(KEY_THEME_LIST + themeId) : null; var reqUrl = API_THEME_URL + themeId; + var topData = null; if (lastID) { reqUrl += '/before/' + lastID; + } else { + topData = this._safeStorage(KEY_THEME_TOPDATA + themeId); } var networking = this._safeFetch(reqUrl); var merged = new Promise((resolve, reject) => { - Promise.all([localStorage, networking]) + Promise.all([localStorage, networking, topData]) .then((values) => { var error, result; result = this._mergeReadState(values[0], values[1]); @@ -153,6 +165,16 @@ DataRepository.prototype.fetchThemeStories = function(themeId: Number, lastID?: if (error) { reject(error); } else { + var topDataRet; + if (values[1] && values[1].editors) { + topDataRet = {}; + topDataRet.description = values[1].description; + topDataRet.background = values[1].background; + topDataRet.editors = values[1].editors; + } else { + topDataRet = values[2]; + } + result.topData = topDataRet; resolve(result); } }); @@ -161,7 +183,7 @@ DataRepository.prototype.fetchThemeStories = function(themeId: Number, lastID?: return merged; }; -DataRepository.prototype.saveStories = function(themeList: object, +DataRepository.prototype.saveStories = function(themeList: object, topData: object, callback?: ?(error: ?Error, result: ?Object) => void ) { var homeList = themeList[0]; @@ -182,6 +204,14 @@ DataRepository.prototype.saveStories = function(themeList: object, } } } + + for (var theme in topData) { + if (topData.hasOwnProperty(theme)) { + //console.log(theme, topData[key]); + keyValuePairs.push([KEY_THEME_TOPDATA + theme, JSON.stringify(topData[theme])]); + } + } + AsyncStorage.multiSet(keyValuePairs, callback); }; diff --git a/DefaultViewPageIndicator.js b/DefaultViewPageIndicator.js new file mode 100644 index 0000000..e3936a8 --- /dev/null +++ b/DefaultViewPageIndicator.js @@ -0,0 +1,102 @@ +'use strict'; + +var React = require('react-native'); +var { + Dimensions, + StyleSheet, + Text, + TouchableOpacity, + View, + Animated, +} = React; + +var deviceWidth = Dimensions.get('window').width; +var DOT_SIZE = 6; +var DOT_SAPCE = 3; + +var styles = StyleSheet.create({ + tab: { + alignItems: 'center', + }, + + tabs: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + + dot: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: DOT_SIZE / 2, + backgroundColor: 'rgba(100,100,100,0.5)', + marginLeft: DOT_SAPCE, + marginRight: DOT_SAPCE, + }, + + curDot: { + position: 'absolute', + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: DOT_SIZE / 2, + backgroundColor: 'rgba(250,250,250,0.8)', + margin: DOT_SAPCE, + bottom: 0, + }, +}); + +var DefaultViewPageIndicator = React.createClass({ + propTypes: { + goToPage: React.PropTypes.func, + activePage: React.PropTypes.number, + pageCount: React.PropTypes.number + }, + + getInitialState() { + return { + viewWidth: 0, + }; + }, + + renderIndicator(page) { + //var isTabActive = this.props.activePage === page; + return ( + this.props.goToPage(page)}> + + + ); + }, + + render() { + var pageCount = this.props.pageCount; + var itemWidth = DOT_SIZE + (DOT_SAPCE * 2); + var offset = (this.state.viewWidth - itemWidth * pageCount) / 2 + itemWidth * this.props.activePage; + + var left = offset; /*this.state.offsetX.interpolate({ + inputRange: [0, 1], outputRange: [0, offset] + });*/ + + var indicators = []; + for (var i = 0; i < pageCount; i++) { + indicators.push(this.renderIndicator(i)) + } + + return ( + { + var viewWidth = event.nativeEvent.layout.width; + if (!viewWidth || this.state.viewWidth === viewWidth) { + return; + } + this.setState({ + viewWidth: viewWidth, + }); + }}> + {indicators} + + + ); + }, +}); + +module.exports = DefaultViewPageIndicator; diff --git a/ListScreen.js b/ListScreen.js index 44ba74e..1cd7371 100644 --- a/ListScreen.js +++ b/ListScreen.js @@ -21,6 +21,7 @@ var StoryItem = require('./StoryItem'); var ThemesList = require('./ThemesList'); var DataRepository = require('./DataRepository'); var SwipeRefreshLayoutAndroid = require('./SwipeRereshLayout'); +var ViewPager = require('./ViewPager'); var API_LATEST_URL = 'http://news.at.zhihu.com/api/4/news/latest'; var API_HISTORY_URL = 'http://news.at.zhihu.com/api/4/news/before/'; @@ -44,6 +45,7 @@ var repository = new DataRepository(); var dataCache = { dataForTheme: {}, + topDataForTheme: {}, sectionsForTheme: {}, lastID: {}, }; @@ -68,18 +70,23 @@ var ListScreen = React.createClass({ sectionHeaderHasChanged: (s1, s2) => s1 !== s2, }); + var headerDataSource = new ViewPager.DataSource({ + pageHasChanged: (p1, p2) => p1 !== p2, + }); + return { isLoading: false, isLoadingTail: false, theme: null, dataSource: dataSource, + headerDataSource: headerDataSource, }; }, componentWillMount: function() { BackAndroid.addEventListener('hardwareBackPress', this._handleBackButtonPress); }, componentWillUnmount: function() { - repository.saveStories(dataCache.dataForTheme); + repository.saveStories(dataCache.dataForTheme, dataCache.topDataForTheme); }, _handleBackButtonPress: function() { if (this.state.theme) { @@ -101,6 +108,7 @@ var ListScreen = React.createClass({ dataBlob = isInTheme ? [] : {}; } var sectionIDs = dataCache.sectionsForTheme[themeId]; + var topData = dataCache.topDataForTheme[themeId]; this.setState({ isLoading: isRefresh, @@ -113,6 +121,7 @@ var ListScreen = React.createClass({ .then((responseData) => { var newLastID; var dataSouce; + var headerDataSource = this.state.headerDataSource; if (!isInTheme) { newLastID = responseData.date; var newDataBlob = {}; @@ -131,7 +140,14 @@ var ListScreen = React.createClass({ dataBlob = newDataBlob; sectionIDs = newSectionIDs; + console.log(responseData); + if (isRefresh && responseData.topData) { + topData = responseData.topData; + headerDataSource = headerDataSource.cloneWithPages(topData.slice()) + } + dataSouce = this.state.dataSource.cloneWithRowsAndSections(newDataBlob, newSectionIDs, null); + } else { var length = responseData.stories.length; if (length > 0) { @@ -159,6 +175,7 @@ var ListScreen = React.createClass({ isLoadingTail: (isRefresh ? this.state.isLoadingTail : false), theme: this.state.theme, dataSource: dataSouce, + headerDataSource: headerDataSource, }); this.swipeRefreshLayout && this.swipeRefreshLayout.finishRefresh(); @@ -175,6 +192,36 @@ var ListScreen = React.createClass({ }) .done(); }, + _renderPage: function( + story: Object, + pageID: number | string,) { + return ( + + + + {story.title} + + + + ) + }, + _renderHeader: function() { + if (this.state.theme) { + + } else { + return ( + + + + ); + } + }, getSectionTitle: function(str) { var date = parseDateFromYYYYMMdd(str); if (date.toDateString() == new Date().toDateString()) { @@ -273,6 +320,7 @@ var ListScreen = React.createClass({ keyboardDismissMode="on-drag" keyboardShouldPersistTaps={true} showsVerticalScrollIndicator={false} + renderHeader={this._renderHeader} />; var title = this.state.theme ? this.state.theme.name : '首页'; return ( @@ -330,6 +378,27 @@ var styles = StyleSheet.create({ color: '#888888', margin: 10, marginLeft: 16, + }, + headerPager: { + height: 200, + }, + headerItem: { + flex: 1, + height: 200, + flexDirection: 'row', + }, + headerTitleContainer: { + flex: 1, + alignSelf: 'flex-end', + padding: 10, + backgroundColor: 'rgba(0,0,0,0.2)', + }, + headerTitle: { + flex: 1, + fontSize: 18, + fontWeight: '500', + color: 'white', + marginBottom: 10, } }); diff --git a/ViewPager.js b/ViewPager.js new file mode 100644 index 0000000..8b05480 --- /dev/null +++ b/ViewPager.js @@ -0,0 +1,257 @@ +'use strict'; + +var React = require('react-native'); +var { + Dimensions, + Text, + View, + TouchableOpacity, + PanResponder, + Animated, + PropTypes, + StyleSheet, +} = React; + +var StaticRenderer = require('StaticRenderer'); + +var DefaultViewPageIndicator = require('./DefaultViewPageIndicator'); +var deviceWidth = Dimensions.get('window').width; +var ViewPagerDataSource = require('./ViewPagerDataSource'); + +var ViewPager = React.createClass({ + + statics: { + DataSource: ViewPagerDataSource, + }, + + propTypes: { + ...View.propTypes, + dataSource: PropTypes.instanceOf(ViewPagerDataSource).isRequired, + renderPage: PropTypes.func.isRequired, + isLoop: PropTypes.bool, + locked: PropTypes.bool, + }, + + getDefaultProps() { + return { + isLoop: false, + locked: false, + } + }, + + getInitialState() { + return { + currentPage: 0, + viewWidth: 0, + childIndex: 0, + scrollValue: new Animated.Value(0) + }; + }, + + componentWillMount() { + var release = (e, gestureState) => { + var relativeGestureDistance = gestureState.dx / deviceWidth, + //lastPageIndex = this.props.children.length - 1, + vx = gestureState.vx; + + var step = 0; + if (relativeGestureDistance < -0.5 || (relativeGestureDistance < 0 && vx <= 0.5)) { + step = 1; + } else if (relativeGestureDistance > 0.5 || (relativeGestureDistance > 0 && vx >= 0.5)) { + step = -1; + } + + this.props.hasTouch && this.props.hasTouch(false); + + this._movePage(step); + } + + this._panResponder = PanResponder.create({ + // Claim responder if it's a horizontal pan + onMoveShouldSetPanResponder: (e, gestureState) => { + if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) { + if (/* (gestureState.moveX <= this.props.edgeHitWidth || + gestureState.moveX >= deviceWidth - this.props.edgeHitWidth) && */ + this.props.locked !== true && !this.state.fling) { + this.props.hasTouch && this.props.hasTouch(true); + return true; + } + } + }, + + // Touch is released, scroll to the one that you're closest to + onPanResponderRelease: release, + onPanResponderTerminate: release, + + // Dragging, move the view with the touch + onPanResponderMove: (e, gestureState) => { + var dx = gestureState.dx; + var offsetX = -dx / this.state.viewWidth + this.state.childIndex; + this.state.scrollValue.setValue(offsetX); + }, + }); + }, + + goToPage(pageNumber) { + console.log('goToPage: ', pageNumber); + var pageCount = this.props.dataSource.getPageCount(); + if (pageNumber < 0 || pageNumber >= pageCount) { + console.error('Invalid page number: ', pageNumber); + return + } + + var step = pageNumber - this.state.currentPage; + this._movePage(step); + }, + + _movePage(step) { + var pageCount = this.props.dataSource.getPageCount(); + var pageNumber = this.state.currentPage + step; + + if (this.props.isLoop) { + pageNumber = (pageNumber + pageCount) % pageCount; + } else { + pageNumber = Math.min(Math.max(0, pageNumber), pageCount - 1); + } + + this.props.onChangeTab && this.props.onChangeTab({ + i: pageNumber, ref: this.props.children[pageNumber] + }); + + var moved = pageNumber !== this.state.currentPage; + + var scrollStep = (moved ? step : 0) + this.state.childIndex; + + this.state.fling = true; + Animated.spring(this.state.scrollValue, {toValue: scrollStep, friction: 10, tension: 50}) + .start((event) => { + if (event.finished) { + this.state.fling = false; + this.setState({ + currentPage: pageNumber, + }); + } + }); + }, + + renderPageIndicator(props) { + if (this.props.renderPageIndicator === false) { + return null; + } else if (this.props.renderPageIndicator) { + return React.cloneElement(this.props.renderPageIndicator(), props); + } else { + return ( + + + + ); + } + }, + + _getPage(pageIdx: number) { + var dataSource = this.props.dataSource; + var pageID = dataSource.pageIdentities[pageIdx]; + return ( + + ); + }, + + render() { + console.log('render()', this.state.currentPage); + var dataSource = this.props.dataSource; + var pageIDs = dataSource.pageIdentities; + + var bodyComponents = []; + + var pagesNum = 0; + var hasLeft = false; + var viewWidth = this.state.viewWidth; + + if(pageIDs.length > 0 && viewWidth > 0) { + // left page + if (this.state.currentPage > 0) { + bodyComponents.push(this._getPage(this.state.currentPage - 1)); + pagesNum++; + hasLeft = true; + } else if (this.state.currentPage == 0 && this.props.isLoop) { + bodyComponents.push(this._getPage(pageIDs.length - 1)); + pagesNum++; + hasLeft = true; + } + + // center page + bodyComponents.push(this._getPage(this.state.currentPage)); + pagesNum++; + + // right page + if (this.state.currentPage < pageIDs.length - 1) { + bodyComponents.push(this._getPage(this.state.currentPage + 1)); + pagesNum++; + } else if (this.state.currentPage == pageIDs.length - 1 && this.props.isLoop) { + bodyComponents.push(this._getPage(0)); + pagesNum++; + } + } + + var sceneContainerStyle = { + width: this.state.viewWidth * pagesNum, + flex: 1, + flexDirection: 'row' + }; + + this.state.childIndex = hasLeft ? 1 : 0; + this.state.scrollValue.setValue(this.state.childIndex); + var translateX = this.state.scrollValue.interpolate({ + inputRange: [0, 1], outputRange: [0, -viewWidth] + }); + + return ( + { + // console.log('ViewPager.onLayout()'); + var viewWidth = event.nativeEvent.layout.width; + if (!viewWidth || this.state.viewWidth === viewWidth) { + return; + } + this.setState({ + currentPage: this.state.currentPage, + viewWidth: viewWidth, + }); + }} + > + + + {bodyComponents} + + + {this.renderPageIndicator({goToPage: this.goToPage, + pageCount: pageIDs.length, + activePage: this.state.currentPage, + scrollValue: this.state.scrollValue, + })} + + ); + } +}); + +var styles = StyleSheet.create({ + indicators: { + flex: 1, + alignItems: 'center', + position: 'absolute', + bottom: 10, + left: 0, + right: 0, + }, +}); + +module.exports = ViewPager; diff --git a/ViewPagerDataSource.js b/ViewPagerDataSource.js new file mode 100644 index 0000000..445bad0 --- /dev/null +++ b/ViewPagerDataSource.js @@ -0,0 +1,134 @@ +'use strict'; + +var invariant = require('invariant'); +var isEmpty = require('isEmpty'); +var warning = require('warning'); + +function defaultGetPageData( + dataBlob: any, + pageID: number | string, +): any { + return dataBlob[pageID]; +} + +type differType = (data1: any, data2: any) => bool; + +type ParamType = { + pageHasChanged: differType; + getPageData: ?typeof defaultGetPageData; +} + +class ViewPagerDataSource { + + constructor(params: ParamType) { + this._getPageData = params.getPageData || defaultGetPageData; + this._pageHasChanged = params.pageHasChanged; + + this.pageIdentities = []; + } + + cloneWithPages( + dataBlob: any, + pageIdentities: ?Array, + ): ViewPagerDataSource { + + var newSource = new ViewPagerDataSource({ + getPageData: this._getPageData, + pageHasChanged: this._pageHasChanged, + }); + + newSource._dataBlob = dataBlob; + + if (pageIdentities) { + newSource.pageIdentities = pageIdentities; + } else { + newSource.pageIdentities = Object.keys(dataBlob); + } + + newSource._cachedPageCount = newSource.pageIdentities.length; + newSource._calculateDirtyPages( + this._dataBlob, + this.pageIdentities + ); + return newSource; + } + + getPageCount(): number { + return this._cachedPageCount; + } + + /** + * Returns if the row is dirtied and needs to be rerendered + */ + pageShouldUpdate(pageIndex: number): bool { + var needsUpdate = this._dirtyPages[pageIndex]; + warning(needsUpdate !== undefined, + 'missing dirtyBit for section, page: ' + pageIndex); + return needsUpdate; + } + + /** + * Gets the data required to render the page + */ + getPageData(pageIndex: number): any { + if (!this.getPageData) { + return null; + } + var pageID = this.pageIdentities[pageIndex]; + warning(pageID !== undefined, + 'renderPage called on invalid section: ' + pageID); + return this._dataBlob[pageID]; + } + + /** + * Private members and methods. + */ + + _getPageData: typeof defaultGetPageData; + _pageHasChanged: differType; + + _dataBlob: any; + _dirtyPages: Array; + _cachedRowCount: number; + + pageIdentities: Array; + + _calculateDirtyPages( + prevDataBlob: any, + prevPageIDs: Array, + ): void { + // construct a hashmap of the existing (old) id arrays + var prevPagesHash = keyedDictionaryFromArray(prevPageIDs); + this._dirtyPages = []; + + var dirty; + for (var sIndex = 0; sIndex < this.pageIdentities.length; sIndex++) { + var pageID = this.pageIdentities[sIndex]; + dirty = !prevPagesHash[pageID]; + var pageHasChanged = this._pageHasChanged + if (!dirty && pageHasChanged) { + dirty = pageHasChanged( + this._getPageData(prevDataBlob, pageID), + this._getPageData(this._dataBlob, pageID) + ); + } + this._dirtyPages.push(!!dirty); + } + } + +} + +function keyedDictionaryFromArray(arr) { + if (isEmpty(arr)) { + return {}; + } + var result = {}; + for (var ii = 0; ii < arr.length; ii++) { + var key = arr[ii]; + warning(!result[key], 'Value appears more than once in array: ' + key); + result[key] = true; + } + return result; +} + +module.exports = ViewPagerDataSource;