diff --git a/README.md b/README.md index a6e94566e..4dcf2b17b 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Props marked with `*` are **mandatory**: | isConnectionOpened | Sets the connection status. If set, upon reconnection smartcharts will either patch missing tick data or refresh the chart, depending on granularity; if not set, it is assumed that connection is always opened. Defaults to `undefined`. | | onMessage | SmartCharts will send notifications via this callback, should it be provided. Each notification will have the following structure: `{ text, type, category }`. | | isAnimationEnabled | Determine whether chart animation is enabled or disabled. It may needs to be disabled for better performance. Defaults to `true`. | -| isVerticalScrollEnabled | Determine whether verticall scroll on the chart outside Y-axis is disabled. It may need to be disabled for mobile app version to scroll the page up or down instead of the chart. Defaults to `true`. | +| isVerticalScrollEnabled | Determine whether verticall scroll on the chart outside Y-axis is disabled while it is forced on the nearest scrollable parent instead. It may need to be disabled for mobile app version to scroll the page up or down instead of the chart. In this case, when scroll delta exceeds 10px, the page will be force-scrolled fully in a respective direction. Defaults to `true`. | | showLastDigitStats | Shows last digits stats. Defaults to `false`. | | scrollToEpoch | Scrolls the chart to the leftmost side and sets the last spot/bar as the first visible spot/bar in the chart. Also, it disables scrolling until the chart reaches the 3/4 of the width of the main pane of the chart. Defaults to `null`. | | diff --git a/chart_app/lib/src/chart_app.dart b/chart_app/lib/src/chart_app.dart index 74283a07e..ccc697fd0 100644 --- a/chart_app/lib/src/chart_app.dart +++ b/chart_app/lib/src/chart_app.dart @@ -34,6 +34,9 @@ class ChartApp { bool _prevShowChart = false; + /// height of xAxis + double xAxisHeight = 24; + /// width of yAxis double yAxisWidth = 60; @@ -73,13 +76,14 @@ class ChartApp { feedModel.newChart(); } - /// Calculates the width of yAxis + /// Calculates the width of yAxis and sets the height of xAxis void calculateTickWidth() { yAxisWidth = calculateYAxisWidth( feedModel.ticks, configModel.theme, configModel.pipSize, ); + xAxisHeight = configModel.theme.gridStyle.xLabelsAreaHeight; currentTickWidth = calculateCurrentTickWidth( feedModel.ticks, diff --git a/chart_app/lib/src/interop/dart_interop.dart b/chart_app/lib/src/interop/dart_interop.dart index a93deed47..bb61b1b33 100644 --- a/chart_app/lib/src/interop/dart_interop.dart +++ b/chart_app/lib/src/interop/dart_interop.dart @@ -30,6 +30,12 @@ void initDartInterop(ChartApp app) { JsObject _exposeApp(ChartApp app) { final JsObject jsObject = JsObject(context['Object']); + setProperty( + jsObject, + 'getXAxisHeight', + allowInterop(() => app.xAxisHeight), + ); + setProperty( jsObject, 'getYAxisWidth', @@ -84,6 +90,12 @@ JsObject _exposeApp(ChartApp app) { ), ); + setProperty( + jsObject, + 'toggleXScrollBlock', + allowInterop(app.wrappedController.toggleXScrollBlock), + ); + setProperty( jsObject, 'toggleDataFitMode', diff --git a/chart_app/lib/src/misc/wrapped_controller.dart b/chart_app/lib/src/misc/wrapped_controller.dart index 870d6a015..b552ec778 100644 --- a/chart_app/lib/src/misc/wrapped_controller.dart +++ b/chart_app/lib/src/misc/wrapped_controller.dart @@ -44,6 +44,17 @@ class WrappedController { } } + /// Block/Unblock horizontal scroll on the chart. + // ignore: avoid_positional_boolean_parameters + void toggleXScrollBlock(bool isXScrollBlocked) { + try { + _chartController.toggleXScrollBlock + ?.call(isXScrollBlocked: isXScrollBlocked); + } catch (_) { + return; + } + } + /// Scroll chart visible area to the newest data. // ignore: avoid_positional_boolean_parameters void toggleDataFitMode(bool dataFitMode) { diff --git a/src/store/ChartAdapterStore.ts b/src/store/ChartAdapterStore.ts index f21a2731e..6e36b2dca 100644 --- a/src/store/ChartAdapterStore.ts +++ b/src/store/ChartAdapterStore.ts @@ -24,6 +24,7 @@ export default class ChartAdapterStore { msPerPx?: number; drawingHoverIndex: number | undefined | null = null; isDataFitModeEnabled = false; + isXScrollBlocked = false; painter = new Painter(); drawingColor = 0; isScaled = false; @@ -41,17 +42,29 @@ export default class ChartAdapterStore { bottomIndex: 0, }; touchValues: { + deltaXTotal?: number; + deltaYTotal?: number; + multiTouch?: boolean; + touchIds?: number[]; x?: number; y?: number; - yOnTouchEnd?: number; - } = {}; + } = { + multiTouch: false, + deltaXTotal: 0, + deltaYTotal: 0, + touchIds: [], + x: 0, + y: 0, + }; isOverFlutterCharts = false; - enableVerticalScrollTimer?: ReturnType; - scrollChartParentOnTouchTimer?: ReturnType; + clearTouchDeltasTimer?: ReturnType; + enableXScrollTimer?: ReturnType; + enableYScrollTimer?: ReturnType; constructor(mainStore: MainStore) { makeObservable(this, { + clearTouchDeltasTimer: observable, onMount: action.bound, onTickHistory: action.bound, onTouch: action.bound, @@ -62,11 +75,12 @@ export default class ChartAdapterStore { onQuoteAreaChanged: action.bound, setMsPerPx: action.bound, newChart: action.bound, - enableVerticalScrollTimer: observable, + enableXScrollTimer: observable, + enableYScrollTimer: observable, scale: action.bound, scrollableChartParent: computed, - scrollChartParentOnTouchTimer: observable, toggleDataFitMode: action.bound, + toggleXScrollBlock: action.bound, touchValues: observable, onCrosshairMove: action.bound, isDataFitModeEnabled: observable, @@ -218,8 +232,9 @@ export default class ChartAdapterStore { window.flutterChartElement?.removeEventListener('touchend', this.onTouch, { capture: true }); window.flutterChartElement?.removeEventListener('dblclick', this.onDoubleClick, { capture: true }); window.removeEventListener('mousemove', this.onMouseMove, { capture: true }); - clearTimeout(this.enableVerticalScrollTimer); - clearTimeout(this.scrollChartParentOnTouchTimer); + clearTimeout(this.clearTouchDeltasTimer); + clearTimeout(this.enableXScrollTimer); + clearTimeout(this.enableYScrollTimer); } onChartLoad() { @@ -237,38 +252,84 @@ export default class ChartAdapterStore { } onTouch(e: TouchEvent) { - const chartNode = this.mainStore.chart.chartNode; // Prevent vertical scroll on the chart for touch devices by forcing scroll on a scrollable parent of the chart: - if ( - chartNode && - this.scrollableChartParent && - !this.mainStore.state.isVerticalScrollEnabled && - e.touches.length === 1 - ) { - const { pageX, screenX, screenY } = e.touches[0]; - if (['touchstart', 'touchend'].includes(e.type)) { - this.touchValues = e.type === 'touchstart' ? { x: screenX, y: screenY } : { yOnTouchEnd: screenY }; - } else if (e.type === 'touchmove') { - const nonScrollableAreaWidth = chartNode.offsetWidth - this.mainStore.chart.yAxisWidth; - const { left } = chartNode.getBoundingClientRect(); + const chartNode = this.mainStore.chart.chartNode; + if (chartNode && this.scrollableChartParent && !this.mainStore.state.isVerticalScrollEnabled) { + if (this.touchValues.multiTouch) { + if (e.type === 'touchend') { + this.touchValues.touchIds = this.touchValues.touchIds?.filter( + id => id === e.changedTouches[0].identifier + ); + this.touchValues = { multiTouch: !!this.touchValues.touchIds?.length }; + } + return; + } + if (e.touches.length > 1) { + this.touchValues = { multiTouch: true }; + this.touchValues.touchIds = Array.from(e.touches).map(touch => touch.identifier); + return; + } + + const { pageX, pageY } = e.changedTouches[0]; + + if (['touchmove', 'touchend'].includes(e.type)) { + const forcedScrollAreaWidth = chartNode.offsetWidth - this.mainStore.chart.yAxisWidth; + const forcedScrollAreaHeight = chartNode.offsetHeight - this.mainStore.chart.xAxisHeight; + const { top, left } = chartNode.getBoundingClientRect(); + const xCoord = pageX - left; + const yCoord = pageY - top; + const isForcedScrollArea = xCoord < forcedScrollAreaWidth && yCoord < forcedScrollAreaHeight; if (this.touchValues.x && this.touchValues.y) { - const deltaX = Math.abs(screenX - this.touchValues.x); - const deltaY = Math.abs(screenY - this.touchValues.y); + const xDiff = this.touchValues.x - pageX; + const yDiff = this.touchValues.y - pageY; + const deltaXTotal = (this.touchValues.deltaXTotal ?? 0) + xDiff; + const deltaYTotal = (this.touchValues.deltaYTotal ?? 0) + yDiff; + const deltaX = e.type === 'touchend' ? Math.abs(deltaXTotal) : Math.abs(xDiff); + const deltaY = e.type === 'touchend' ? Math.abs(deltaYTotal) : Math.abs(yDiff); const isVerticalScroll = deltaY > deltaX; - const x = pageX - left; - if (x < nonScrollableAreaWidth && isVerticalScroll && !this.scrollChartParentOnTouchTimer) { - this.touchValues.yOnTouchEnd = undefined; - this.scrollChartParentOnTouchTimer = setTimeout(() => { - this.scrollableChartParent?.scrollBy({ - top: screenY - Number(this.touchValues.yOnTouchEnd ?? this.touchValues.y), + this.touchValues = { ...this.touchValues, deltaXTotal, deltaYTotal }; + + if (isForcedScrollArea && isVerticalScroll) { + const shouldForceMaxScroll = + Math.abs(Number(this.touchValues.deltaYTotal)) > 10 && e.type === 'touchend'; + if (!this.isXScrollBlocked) this.toggleXScrollBlock(); + if (shouldForceMaxScroll) { + // handling max scroll on quick swipe + this.scrollableChartParent?.scrollTo({ + top: + Number(this.touchValues.deltaYTotal) < 0 + ? 0 + : this.scrollableChartParent.scrollHeight, behavior: 'smooth', }); - this.scrollChartParentOnTouchTimer = undefined; - }, 300); + } else if (e.type === 'touchmove') { + // handling slow scroll + this.scrollableChartParent?.scrollBy({ + top: yDiff, + }); + if (!this.clearTouchDeltasTimer) { + this.clearTouchDeltasTimer = setTimeout(() => { + // clearing total deltas to avoid triggering max scroll after the slow scroll + this.touchValues = { ...this.touchValues, deltaYTotal: 0, deltaXTotal: 0 }; + this.clearTouchDeltasTimer = undefined; + }, 100); + } + } } } - this.touchValues = { x: screenX, y: screenY }; + this.touchValues = { ...this.touchValues, x: pageX, y: pageY }; + if (e.type === 'touchend' && this.isXScrollBlocked) { + this.enableXScrollTimer = setTimeout(() => { + this.toggleXScrollBlock(false); + }, 100); + } + } + if (['touchstart', 'touchend'].includes(e.type)) { + this.touchValues = + e.type === 'touchstart' + ? { x: pageX, y: pageY } + : { deltaYTotal: this.touchValues.deltaYTotal, deltaXTotal: this.touchValues.deltaXTotal }; } } } @@ -284,11 +345,11 @@ export default class ChartAdapterStore { const isVerticalScroll = e.deltaY && e.deltaX === 0; const x = e.pageX - left; if (x < nonScrollableAreaWidth && isVerticalScroll) { - if (this.enableVerticalScrollTimer) return; + if (this.enableYScrollTimer) return; chartNode.style.pointerEvents = 'none'; - this.enableVerticalScrollTimer = setTimeout(() => { + this.enableYScrollTimer = setTimeout(() => { chartNode.style.pointerEvents = 'auto'; - this.enableVerticalScrollTimer = undefined; + this.enableYScrollTimer = undefined; }, 300); return; } @@ -474,6 +535,11 @@ export default class ChartAdapterStore { } } + toggleXScrollBlock = (isBlocked = true) => { + this.isXScrollBlocked = isBlocked; + window.flutterChart?.app.toggleXScrollBlock(isBlocked); + }; + toggleDataFitMode = () => { this.isDataFitModeEnabled = !this.isDataFitModeEnabled; window.flutterChart?.app.toggleDataFitMode(this.isDataFitModeEnabled); diff --git a/src/store/ChartStore.ts b/src/store/ChartStore.ts index 269e9b6c7..912d72ebf 100644 --- a/src/store/ChartStore.ts +++ b/src/store/ChartStore.ts @@ -71,6 +71,7 @@ class ChartStore { networkStatus: observable, serverTime: observable, shouldRenderDialogs: observable, + xAxisHeight: computed, yAxisWidth: computed, lastQuote: observable, _initChart: action.bound, @@ -134,6 +135,10 @@ class ChartStore { return this.currentCloseQuote()?.Close; } + get xAxisHeight(): number { + return window.flutterChart?.app.getXAxisHeight() || 24; + } + get yAxisWidth(): number { return window.flutterChart?.app.getYAxisWidth() || 60; } diff --git a/src/types/props.types.ts b/src/types/props.types.ts index 1d10756a6..07b9ab245 100644 --- a/src/types/props.types.ts +++ b/src/types/props.types.ts @@ -378,6 +378,7 @@ export type TDrawingToolConfig = { export type TFlutterChart = { app: { + getXAxisHeight: () => number; getYAxisWidth: () => number; getCurrentTickWidth: () => number; newChart: (payload: TNewChartPayload) => void; @@ -396,6 +397,7 @@ export type TFlutterChart = { scale: (scale: number) => number; scroll: (pxShift: number) => void; toggleDataFitMode: (isDataFitEnabled: boolean) => void; + toggleXScrollBlock: (isXScrollBlocked: boolean) => void; scrollToLastTick: () => void; addOrUpdateIndicator: (config: string, index?: number) => void; };