diff --git a/README.md b/README.md index f4fe5ee..4196441 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,27 @@ # React Native KLine View + +## Development + +To start the project for: + + - Android: + - in `example` dir + - `yarn install --force` + - `yarn android` + - to see logs: `adb logcat`, needs grep as this prints everything + + - ios: + - in `example` dir: `yarn install --force` + - in `example/ios` dir: `pod install` + - in `example` dir: `yarn ios` (or start from xcode) + - sometimes it throws code developer signature. if it shows the application started successfully means the simulator works fine + - to see logs start in xcode + + + +## Intro +
React Native KLine View
@@ -74,6 +96,7 @@ yarn add react-native-kline-view@https://github.com/hellohublot/react-native-kli ``` ### iOS Setup + ```bash cd ios && pod install ``` diff --git a/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java b/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java index 7ebe8fe..1cee40c 100755 --- a/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/BaseKLineChartView.java @@ -1428,11 +1428,28 @@ public int getMinScrollX() { return 0; } + public int getExtraScrollX() { + float minVisibleCandles = getMinVisibleCandles(); + int extraScrollX = (int) (mWidth / mScaleX - Math.min(minVisibleCandles * mPointWidth / mScaleX, mWidth / mScaleX * 0.8f)); + // android.util.Log.d("BaseKLineChartView", "getExtraScrollX: " + extraScrollX + ", mScaleX: " + mScaleX + ", minVisibleCandles: " + minVisibleCandles + ", mWidth: " + mWidth); + return extraScrollX; + } + public int getMaxScrollX() { - int contentWidth = (int) Math.max((mDataLen - (mWidth - configManager.paddingRight) / mScaleX), 0); + int contentWidth = (int) Math.max((mDataLen - (mWidth - configManager.paddingRight) / mScaleX + getExtraScrollX()), 0); return contentWidth; } + @Override + protected float getMinVisibleCandles() { + return configManager.minVisibleCandles; + } + + @Override + public float getDataLength() { + return mDataLen; + } + /** * 在主区域画线 * @@ -1989,19 +2006,20 @@ public boolean onSingleTapUp(MotionEvent e) { } public void smoothScrollToEnd() { - int endScrollX = getMaxScrollX(); - int currentScrollX = getScrollOffset(); - int distance = endScrollX - currentScrollX; - - // android.util.Log.d("BaseKLineChartView", "smoothScrollToEnd DEBUG:"); - // android.util.Log.d("BaseKLineChartView", " mDataLen=" + mDataLen + ", mItemCount=" + mItemCount + ", mPointWidth=" + mPointWidth); - // android.util.Log.d("BaseKLineChartView", " mWidth=" + mWidth + ", mScaleX=" + mScaleX + ", paddingRight=" + configManager.paddingRight); - // android.util.Log.d("BaseKLineChartView", " current=" + currentScrollX + ", end=" + endScrollX + ", distance=" + distance); - - // Always scroll to end position, regardless of current position - // This ensures we go to the rightmost position to show the latest data - setScrollX(endScrollX); - // android.util.Log.d("BaseKLineChartView", "Set scroll position to end: " + endScrollX); + int screenWidthInLogicalUnits = getExtraScrollX(); + int endScrollX = (int)(mDataLen + configManager.paddingRight - screenWidthInLogicalUnits); + + setScrollXWithoutMinCandlesLimit(Math.max(0, endScrollX)); + } + + /** + * Set scroll position without applying minVisibleCandles limit + */ + private void setScrollXWithoutMinCandlesLimit(int scrollX) { + int oldX = this.mScrollX; + this.mScrollX = Math.max(0, Math.min(scrollX, (int)mDataLen)); + onScrollChanged(this.mScrollX, 0, oldX, 0); + invalidate(); } // Public getter methods for accessing protected fields diff --git a/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java b/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java index 29895f6..4574834 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/HTKLineConfigManager.java @@ -103,6 +103,8 @@ public class HTKLineConfigManager { public float candleCornerRadius = 0; + public float minVisibleCandles = 5; + public int minuteVolumeCandleColor = Color.RED; public float minuteVolumeCandleWidth = 1.5f; @@ -455,6 +457,11 @@ public void reloadOptionList(Map optionList) { this.candleCornerRadius = candleCornerRadiusValue.floatValue(); } + Number minVisibleCandlesValue = (Number)configList.get("minVisibleCandles"); + if (minVisibleCandlesValue != null) { + this.minVisibleCandles = minVisibleCandlesValue.floatValue(); + } + this.fontFamily = (configList.get("fontFamily")).toString(); this.textColor = ((Number) configList.get("textColor")).intValue(); this.headerTextFontSize = ((Number)configList.get("headerTextFontSize")).floatValue(); diff --git a/android/src/main/java/com/github/fujianlian/klinechart/ScrollAndScaleView.java b/android/src/main/java/com/github/fujianlian/klinechart/ScrollAndScaleView.java index 8ab1668..8ca9f34 100755 --- a/android/src/main/java/com/github/fujianlian/klinechart/ScrollAndScaleView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/ScrollAndScaleView.java @@ -17,6 +17,12 @@ public abstract class ScrollAndScaleView extends RelativeLayout implements GestureDetector.OnGestureListener, ScaleGestureDetector.OnScaleGestureListener { protected int mScrollX = 0; + + /** + * Get minimum visible candles + * @return minimum number of candles that should be visible + */ + protected abstract float getMinVisibleCandles(); protected GestureDetectorCompat mDetector; protected ScaleGestureDetector mScaleDetector; @@ -265,6 +271,20 @@ public boolean isTouch() { */ public abstract int getMaxScrollX(); + /** + * Get the point width + * + * @return + */ + public abstract float getPointWidth(); + + /** + * Get the total data length (itemCount * pointWidth) + * + * @return + */ + public abstract float getDataLength(); + /** * Set ScrollX * @@ -286,7 +306,6 @@ public boolean isMultipleTouch() { protected void checkAndFixScrollX() { int contentSizeWidth = (getMaxScrollX()); - if (mScrollX < getMinScrollX()) { mScrollX = getMinScrollX(); mScroller.forceFinished(true); diff --git a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java index 3ce63bb..f7028ae 100644 --- a/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java +++ b/android/src/main/java/com/github/fujianlian/klinechart/container/HTKLineContainerView.java @@ -79,7 +79,6 @@ public void reloadConfigManager() { klineView.setMTextSize(klineView.configManager.candleTextFontSize); klineView.setMTextColor(klineView.configManager.candleTextColor); klineView.reloadColor(); - Boolean isEnd = klineView.getScrollOffset() >= klineView.getMaxScrollX(); int previousScrollX = klineView.getScrollOffset(); klineView.notifyChanged(); @@ -87,8 +86,9 @@ public void reloadConfigManager() { // 调整滚动位置以补偿新增的数据 int newScrollX = previousScrollX + klineView.configManager.scrollPositionAdjustment; klineView.setScrollX(newScrollX); - } else if (isEnd || klineView.configManager.shouldScrollToEnd) { - klineView.setScrollX(klineView.getMaxScrollX()); + } else if (klineView.configManager.shouldScrollToEnd) { + int scrollToEnd = klineView.getMaxScrollX() - klineView.getExtraScrollX(); + klineView.setScrollX(scrollToEnd); } @@ -409,8 +409,9 @@ public void addCandlesticksAtTheEnd(ReadableArray candlesticksArray) { } try { + float endPosition = klineView.getMaxScrollX() - klineView.getExtraScrollX(); // Check if user is currently at the end of the chart - boolean wasAtEnd = klineView.getScrollOffset() >= klineView.getMaxScrollX() - 10; + boolean wasAtEnd = endPosition - 10 <= klineView.getScrollOffset() && klineView.getScrollOffset() <= endPosition + 10; // Get existing model for preserving indicator lists structure KLineEntity templateEntity = null; @@ -469,7 +470,7 @@ public void run() { @Override public void run() { android.util.Log.d("HTKLineContainerView", "Scrolling to end after adding new data"); - klineView.setScrollX(klineView.getMaxScrollX()); + klineView.setScrollX(klineView.getMaxScrollX() - klineView.getExtraScrollX()); } }, 100); // Additional delay for scroll } diff --git a/example/App.js b/example/App.js index c5b8c2f..388f2d3 100644 --- a/example/App.js +++ b/example/App.js @@ -42,6 +42,7 @@ import { const App = () => { + const MIN_VISIBLE_CANDLES = 10 const [isDarkTheme, setIsDarkTheme] = useState(false) const [selectedTimeType, setSelectedTimeType] = useState(2) // Corresponds to 1 minute const [selectedMainIndicator, setSelectedMainIndicator] = useState(1) // Corresponds to MA (1=MA, 2=BOLL) @@ -167,7 +168,8 @@ const App = () => { lastDataLength, currentScrollPosition, showVolumeChart, - candleCornerRadius + candleCornerRadius, + minVisibleCandles: MIN_VISIBLE_CANDLES }, shouldScrollToEnd, kLineViewRef.current ? true : false) setOptionListValue(newOptionList) }, [klineData, selectedMainIndicator, selectedSubIndicator, showVolumeChart, isDarkTheme, selectedTimeType, selectedDrawTool, showIndicatorSelector, showTimeSelector, showDrawToolSelector, drawShouldContinue, optionList, lastDataLength, currentScrollPosition, candleCornerRadius]) @@ -216,7 +218,8 @@ const App = () => { lastDataLength, currentScrollPosition, showVolumeChart, - candleCornerRadius + candleCornerRadius, + minVisibleCandles: MIN_VISIBLE_CANDLES }, false) // Calculate scroll distance adjustment needed (based on item width) diff --git a/example/utils/businessLogic.js b/example/utils/businessLogic.js index 1f66904..acbb500 100644 --- a/example/utils/businessLogic.js +++ b/example/utils/businessLogic.js @@ -282,7 +282,8 @@ export const packOptionList = (modelArray, appState, shouldScrollToEnd = true, u selectedDrawTool, showVolumeChart, candleCornerRadius, - drawShouldContinue + drawShouldContinue, + minVisibleCandles } = appState const theme = ThemeManager.getCurrentTheme(isDarkTheme) @@ -355,6 +356,7 @@ export const packOptionList = (modelArray, appState, shouldScrollToEnd = true, u itemWidth: 8 * pixelRatio, candleWidth: 6 * pixelRatio, candleCornerRadius: candleCornerRadius * pixelRatio, + minVisibleCandles: minVisibleCandles || 5, minuteVolumeCandleColor: processColor(showVolumeChart ? COLOR(0.0941176, 0.509804, 0.831373, 0.501961) : 'transparent'), minuteVolumeCandleWidth: showVolumeChart ? 2 * pixelRatio : 0, macdCandleWidth: 1 * pixelRatio, diff --git a/ios/Classes/HTKLineConfigManager.swift b/ios/Classes/HTKLineConfigManager.swift index 32612eb..26360b9 100644 --- a/ios/Classes/HTKLineConfigManager.swift +++ b/ios/Classes/HTKLineConfigManager.swift @@ -134,6 +134,8 @@ class HTKLineConfigManager: NSObject { var candleCornerRadius: CGFloat = 0 + var minVisibleCandles: CGFloat = 5 + var minuteVolumeCandleWidth: CGFloat = 0 var _minuteVolumeCandleWidth: CGFloat = 0 @@ -448,6 +450,7 @@ class HTKLineConfigManager: NSObject { _minuteVolumeCandleWidth = configList["minuteVolumeCandleWidth"] as? CGFloat ?? 0 _macdCandleWidth = configList["macdCandleWidth"] as? CGFloat ?? 0 candleCornerRadius = configList["candleCornerRadius"] as? CGFloat ?? 0 + minVisibleCandles = configList["minVisibleCandles"] as? CGFloat ?? 5 reloadScrollViewScale(1) paddingTop = configList["paddingTop"] as? CGFloat ?? 0 paddingRight = configList["paddingRight"] as? CGFloat ?? 0 diff --git a/ios/Classes/HTKLineContainerView.swift b/ios/Classes/HTKLineContainerView.swift index 885ed43..3b24075 100644 --- a/ios/Classes/HTKLineContainerView.swift +++ b/ios/Classes/HTKLineContainerView.swift @@ -351,33 +351,48 @@ class HTKLineContainerView: UIView { print("HTKLineContainerView: Using indicator data from React Native - maList.count=\(newModel.maList.count), maVolumeList.count=\(newModel.maVolumeList.count)") } - // Get the scroll position before adding data - let wasAtEnd = klineView.contentOffset.x >= (klineView.contentSize.width - klineView.frame.width - 10) + let rightScreenOffset = klineView.contentOffset.x + bounds.size.width + 1 - configManager.paddingRight + let lastCandlestickOffset = configManager.itemWidth * CGFloat(configManager.modelArray.count) - configManager.itemWidth / 2 + // how many candlesticks +- should it consider to auto scroll to end when new data is added + // if the user is over-scrolled, then the candlesticks have space to appear on screen without scrolling + // and at some point it will enter this range and become auto-scrolling to end + let candlesticksCountOffset = 3 * configManager.itemWidth + // Extra spacing at the end is bounds.size.width + let wasAtEnd = lastCandlestickOffset - candlesticksCountOffset <= rightScreenOffset && rightScreenOffset <= lastCandlestickOffset + candlesticksCountOffset + configManager.paddingRight + + let isOverscrolled = rightScreenOffset > lastCandlestickOffset + candlesticksCountOffset // Add new models to the end of the array configManager.modelArray.append(contentsOf: newModels) - print("HTKLineContainerView: Added \(newModels.count) new candlesticks to the end") - print("HTKLineContainerView: Total candlesticks now: \(configManager.modelArray.count)") - print("HTKLineContainerView: Was at end before adding: \(wasAtEnd)") + // print("HTKLineContainerView: Added \(newModels.count) new candlesticks to the end") + // print("HTKLineContainerView: Total candlesticks now: \(configManager.modelArray.count)") + // print("HTKLineContainerView: Was at end before adding: \(wasAtEnd)") // Force redraw and optionally scroll to end DispatchQueue.main.async { [weak self] in guard let self = self else { return } - print("HTKLineContainerView: Reloading content size after adding candlesticks") + // print("HTKLineContainerView: Reloading content size after adding candlesticks") self.klineView.reloadContentSize() - print("HTKLineContainerView: Triggering redraw after adding candlesticks") + // print("HTKLineContainerView: Triggering redraw after adding candlesticks") self.klineView.setNeedsDisplay() // If user was at the end, keep them at the end if wasAtEnd { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - print("HTKLineContainerView: Scrolling to end after adding new data") - let maxContentOffsetX = max(0, self.klineView.contentSize.width - self.klineView.bounds.size.width) + // print("HTKLineContainerView: Scrolling to end after adding new data") + let maxContentOffsetX = max(0, self.klineView.contentSize.width - 2 * self.klineView.bounds.size.width) self.klineView.reloadContentOffset(maxContentOffsetX, true) } + } else if isOverscrolled { + // If user is overscrolled, force redraw to ensure new candlesticks appear + // print("HTKLineContainerView: User is overscrolled, forcing redraw for new candlesticks") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // print("HTKLineContainerView: Scrolling to end after adding new data") + self.klineView.reloadContentOffset(self.klineView.contentOffset.x + self.configManager.itemWidth / 2, true) + } } } } catch { diff --git a/ios/Classes/HTKLineView.swift b/ios/Classes/HTKLineView.swift index 8ee26d6..ab66ac1 100644 --- a/ios/Classes/HTKLineView.swift +++ b/ios/Classes/HTKLineView.swift @@ -131,16 +131,31 @@ class HTKLineView: UIScrollView { childDraw = wrDraw } - let isEnd = contentOffset.x + 1 + bounds.size.width >= contentSize.width let previousContentOffset = contentOffset.x reloadContentSize() + + let rightScreenOffset = contentOffset.x + bounds.size.width + 1 - configManager.paddingRight + let lastCandlestickOffset = configManager.itemWidth * CGFloat(configManager.modelArray.count) - configManager.itemWidth / 2 + // how many candlesticks +- should it consider to auto scroll to end when new data is added + // if the user is over-scrolled, then the candlesticks have space to appear on screen without scrolling + // and at some point it will enter this range and become auto-scrolling to end + let candlesticksCountOffset = 3 * configManager.itemWidth + // Extra spacing at the end is bounds.size.width + let isEnd = lastCandlestickOffset - candlesticksCountOffset <= rightScreenOffset && rightScreenOffset <= lastCandlestickOffset + candlesticksCountOffset + + // Additional context for debugging over-scroll + let contentWidth = contentSize.width + let maxScrollOffset = max(0, contentWidth - bounds.size.width) + let isOverScrolled = contentOffset.x >= maxScrollOffset + let distanceFromEnd = maxScrollOffset - contentOffset.x + if configManager.shouldAdjustScrollPosition { // Adjust scroll position to compensate for newly added data let newContentOffset = previousContentOffset + configManager.scrollPositionAdjustment reloadContentOffset(newContentOffset, false) } else if configManager.shouldScrollToEnd || isEnd { - let toEndContentOffset = contentSize.width - bounds.size.width + let toEndContentOffset = contentSize.width - 2 * bounds.size.width let distance = abs(contentOffset.x - toEndContentOffset) let animated = distance <= configManager.itemWidth reloadContentOffset(toEndContentOffset, animated) @@ -183,16 +198,19 @@ class HTKLineView: UIScrollView { let contentWidth = configManager.itemWidth * CGFloat(configManager.modelArray.count) + configManager.paddingRight + + bounds.size.width contentSize = CGSize.init(width: contentWidth, height: frame.size.height) } func reloadContentOffset(_ contentOffsetX: CGFloat, _ animated: Bool = false) { - let offsetX = max(0, min(contentOffsetX, contentSize.width - bounds.size.width)) + let allCandlesWidth = configManager.itemWidth * CGFloat(configManager.modelArray.count) + let maxAllowedOffset = max(0, allCandlesWidth - configManager.minVisibleCandles * configManager.itemWidth) + let offsetX = max(0, min(contentOffsetX, maxAllowedOffset)) setContentOffset(CGPoint.init(x: offsetX, y: 0), animated: animated) } func smoothScrollToEnd() { - let endOffsetX = contentSize.width - bounds.size.width + let endOffsetX = contentSize.width - 2 * bounds.size.width reloadContentOffset(endOffsetX, true) } @@ -1034,7 +1052,16 @@ class HTKLineView: UIScrollView { extension HTKLineView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { + let allCandlesWidth = configManager.itemWidth * CGFloat(configManager.modelArray.count) + let maxAllowedOffset = max(0, allCandlesWidth - configManager.minVisibleCandles * configManager.itemWidth) + let contentOffsetX = scrollView.contentOffset.x + + if contentOffsetX > maxAllowedOffset { + scrollView.contentOffset.x = maxAllowedOffset + return + } + var visibleStartIndex = Int(floor(contentOffsetX / configManager.itemWidth)) var visibleEndIndex = Int( ceil((contentOffsetX + scrollView.bounds.size.width) / configManager.itemWidth))