import React, { useEffect, useMemo, ReactElement, memo } from 'react' import { Platform, StyleProp, View, ViewStyle } from 'react-native' import { responsivePositioningOptions, GROUP, SECTION, LOCATION_INPUT, } from '@adalo/constants' import { normalizeAttributes } from 'utils/styles' import { usePushNode, useResponsiveObject, useHandleLayoutChange, useRenderedChildren, useVerticallyExpandedFixedComponent, getLayoutCacheKey, } from 'hooks/responsive' import { RunnerObject, Attributes, Screen, ComponentFixedPosition, Layout, DeviceType, LayoutMetadata, } from 'types' import { IApp } from 'interfaces' import { getDeviceType } from 'utils/device' import { getScreenSize } from 'utils/responsive' const { FIXED_ON_SCROLL } = responsivePositioningOptions export interface ResponsiveComponentProps { branding: unknown ObjectClass: React.FC object: RunnerObject component: Screen getBaseProps: ( obj: RunnerObject, types: Record ) => React.Props renderChildren: (children: RunnerObject[]) => ReactElement[] pushId: string active: boolean visible: boolean visibleOnDevice: boolean setHeight: (pushId: string, obj: RunnerObject, height: number) => void setVisible: ( pushId: string, obj: RunnerObject, visible: boolean, visibleOnDevice: boolean ) => void bindingData: unknown[] adjustedNodeHeight: number parentLayoutMetadata: LayoutMetadata isReusableComponent: boolean previewType: string layoutType: 'body' | 'fixed' app: IApp getFlags: () => Record } const permittedExpandableTypes = new Set([SECTION, GROUP]) const determineLayoutAdjustments = ({ component, object, renderObject, layoutType, positioningLayout, }: { component: Screen object: RunnerObject renderObject: RunnerObject layoutType: ResponsiveComponentProps['layoutType'] positioningLayout: Layout }): { componentFixedPosition: ComponentFixedPosition shouldApplyRepositioning: boolean } => { const widthPercentage = ((positioningLayout: Layout): number => { if (typeof positioningLayout.width === 'string') { return Math.ceil(parseFloat(positioningLayout.width)) } if (!positioningLayout.width) { return 0 } return Math.ceil(positioningLayout.width) })(positioningLayout) const hasRectangleChildFixedOnTop = renderObject.children?.some( child => child.type === SECTION && child.layout.top === 0 ) const isGroupWithRectangleFixedOnTop = object.type === GROUP && hasRectangleChildFixedOnTop // prettier-ignore const isFixedBottom = component.height === object.attributes?.y + object.attributes?.height // prettier-ignore // we only want to expand components that are either a rectangle (section) or // a group that contain rectangles (sections), and only if the width is 100% if ( positioningLayout.top === 20 && widthPercentage >= 100 && typeof positioningLayout.height === 'number' && (object.type === SECTION || isGroupWithRectangleFixedOnTop) ) { return { componentFixedPosition: ComponentFixedPosition.TOP, shouldApplyRepositioning: true, } } // Rectangles or groups containing rectangles are expanded if we detect they // were placed on the very bottom of the screen in the builder if ( typeof positioningLayout.height === 'number' && isFixedBottom && permittedExpandableTypes.has(object.type) && layoutType === 'fixed' && widthPercentage >= 100 ) { return { componentFixedPosition: ComponentFixedPosition.BOTTOM, shouldApplyRepositioning: true, } } // The `useVerticallyExpandedFixedComponent` hook will still run // however the data returned from the hook will not be acted upon return { componentFixedPosition: ComponentFixedPosition.BOTTOM, shouldApplyRepositioning: false, } } export const ResponsiveComponentNoMemo = ({ branding, ObjectClass, object, component, getBaseProps, renderChildren, setHeight, setVisible, active, visible, visibleOnDevice, pushId, bindingData, adjustedNodeHeight, parentLayoutMetadata, previewType, layoutType, app, }: ResponsiveComponentProps): ReactElement | null => { const isFixed = object?.responsivity?.verticalPositioning === FIXED_ON_SCROLL let { nodeHeight, newY } = usePushNode(component.id, pushId, isFixed) const layoutCacheKey = getLayoutCacheKey(previewType) const { renderObject, positioningLayout, layoutMetadata } = useResponsiveObject(object, newY, parentLayoutMetadata, app, layoutCacheKey) const deviceType = useMemo(() => { const { width } = getScreenSize() return getDeviceType(width, app) as DeviceType }, [renderObject]) // apply offset for bottom fixed components if (positioningLayout.bottom === -20) { positioningLayout.bottom = 0 } const { componentFixedPosition, shouldApplyRepositioning } = determineLayoutAdjustments({ component, object, renderObject, layoutType, positioningLayout, }) // this hook must always run const { newTop, newBottom, newHeight } = useVerticallyExpandedFixedComponent( componentFixedPosition, positioningLayout, renderObject, previewType, layoutCacheKey, deviceType ) useEffect(() => { setVisible(pushId, renderObject, visible, visibleOnDevice) }, [visible, pushId, renderObject]) // TODO(dyego) In native in some situations the layout // property gets frozen, there is a card to address this issue: // https://trello.com/c/l74BvoGf/96-top-rectangle-within-a-group-is-pulled-up-to-fill-in-the-inset-area-instead-of-getting-expanded if (Object.isFrozen(renderObject.layout)) { renderObject.layout = { ...renderObject.layout } } // If there are any rectangles with children we have to make sure // the nodeHeight is not smaller than the height of the // renderObject, otherwise the children will overflow the parent // This only applies to top fixed rectangles, since we have to // both decrease their "top" position and increase their base height if ( renderObject.type === SECTION && typeof renderObject?.layout?.height === 'number' && renderObject?.layout?.height > nodeHeight && renderObject.children?.length !== 0 && renderObject.attributes.y === 20 ) { nodeHeight = renderObject.layout.height } // we only want to expand components that are either a rectangle (section) or // a group that contain rectangles (sections), and only if the width is 100% if ( componentFixedPosition === ComponentFixedPosition.TOP && shouldApplyRepositioning ) { if (typeof newTop === 'number') { // we decrease the top by the amount we increased the height positioningLayout.top = newTop } if (typeof newHeight === 'number' && object.type === SECTION) { renderObject.layout.height = newHeight } } // Rectangles or groups containing rectangles are expanded if we detect they // were placed on the very bottom of the screen in the builder if ( componentFixedPosition === ComponentFixedPosition.BOTTOM && shouldApplyRepositioning ) { if (typeof newBottom === 'number') { if (newTop === 0) { // if the component is sticky the bottom property is set // so we can use it to position the component at the bottom positioningLayout.bottom = newBottom * -1 } else { // if the component is non-sticky the top property is set and we increase // the top by the amount we increased the height positioningLayout.top = newTop } } if (typeof newHeight === 'number') { renderObject.layout.height = newHeight nodeHeight = newHeight } } const handleLayoutChange = useHandleLayoutChange({ renderObject, setHeight, nodeHeight: adjustedNodeHeight || nodeHeight, pushId, layoutCacheKey, }) const baseProps = useMemo( () => getBaseProps(renderObject, { newY, nodeHeight: adjustedNodeHeight || nodeHeight, bindingData, parentLayoutMetadata: layoutMetadata, }), [ renderObject, newY, adjustedNodeHeight, nodeHeight, bindingData, active, layoutCacheKey, layoutMetadata, ] ) const renderedChildren = useRenderedChildren( renderObject, renderChildren, visible, active, layoutMetadata, layoutCacheKey ) if (!visible) { return null } if (branding) { renderObject.attributes = normalizeAttributes( renderObject.attributes, branding as Object ) as Attributes } // positioningLayout let child = {renderedChildren} if (renderObject.attributes?.variableHeight) { child = ( {child} ) } const minHeightStyle: StyleProp = {} if (nodeHeight && Platform.OS === 'android') { // The wrapping View must completely wrap it's child for all the content within it to be tappable in android. // If nodeHeight isn't set as the height, it'll only have the object.layout height set, which could be less than // the inner child's height if it has variable height content, and not all the content within it would be tappable. minHeightStyle.minHeight = nodeHeight if (object.type === LOCATION_INPUT) { const { height, borderWidth = 0 } = object.attributes minHeightStyle.minHeight = height + 2 * borderWidth } } return ( {child} ) } export const ResponsiveComponent = memo(ResponsiveComponentNoMemo)