Repository: Instawork/hyperview
Pull request #1112: refactor(demo/scroll-opacity): use ranges to map position with opacity
Author: login: flochtililoch
Created: 2025-03-05T17:36:17Z | Merged: 2025-03-05T22:28:09Z

To increase flexibiility of the component, read position (input) and opacity (output) ranges from XML attribute, and use these to calculate the expected opacity. Update the example to show-case two different use.
Also add the ability to pass styles to the wrapping view.


https://github.com/user-attachments/assets/11c12a96-55a9-46f9-bc2e-1e0968298d69




Diff:
diff --git a/demo/backend/advanced/community/elements/scroll-opacity/_styles.xml.njk b/demo/backend/advanced/community/elements/scroll-opacity/_styles.xml.njk
index 226a26fb0..5d39ae3ea 100644
--- a/demo/backend/advanced/community/elements/scroll-opacity/_styles.xml.njk
+++ b/demo/backend/advanced/community/elements/scroll-opacity/_styles.xml.njk
@@ -3,3 +3,9 @@
   marginHorizontal="24"
   marginBottom="16"
 />
+
+<style
+  id="instructions-text"
+  flex="1"
+  alignItems="flex-end"
+/>
diff --git a/demo/backend/advanced/community/elements/scroll-opacity/index.xml.njk b/demo/backend/advanced/community/elements/scroll-opacity/index.xml.njk
index 9f28311df..8ad9b803a 100644
--- a/demo/backend/advanced/community/elements/scroll-opacity/index.xml.njk
+++ b/demo/backend/advanced/community/elements/scroll-opacity/index.xml.njk
@@ -18,11 +18,20 @@ hv_title: "Scroll Opacity"
     <scroll-opacity:scroll-opacity
       xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
       scroll-opacity:context-key="scroll-view"
-      scroll-opacity:distance="20"
-      scroll-opacity:initial-opacity="0"
+      scroll-opacity:scroll-range="[30, 100]"
+      scroll-opacity:opacity-range="[0, 1]"
     >
       <text style="header-title" key="title">{{ hv_title }}</text>
     </scroll-opacity:scroll-opacity>
+    <scroll-opacity:scroll-opacity
+      xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
+      style="instructions-text"
+      scroll-opacity:context-key="scroll-view"
+      scroll-opacity:scroll-range="[0, 20]"
+      scroll-opacity:opacity-range="[1, 0]"
+    >
+      <text key="instructions-text">Scroll down…</text>
+    </scroll-opacity:scroll-opacity>
   </header>
   <view
     style="main"
diff --git a/demo/schema/scroll-opacity.xsd b/demo/schema/scroll-opacity.xsd
index 95f267057..ce8c02561 100644
--- a/demo/schema/scroll-opacity.xsd
+++ b/demo/schema/scroll-opacity.xsd
@@ -3,17 +3,29 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="scroll-range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
+    </xs:restriction>
+  </xs:simpleType>
+  <xs:simpleType name="opacity-range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[[0-9]\.?[0-9]*,\s?[0-9]\.?[0-9]*\]" />
+    </xs:restriction>
+  </xs:simpleType>
   <xs:element name="scroll-opacity">
     <xs:complexType>
       <xs:sequence>
         <xs:any minOccurs="0" maxOccurs="unbounded" />
       </xs:sequence>
+      <xs:attribute name="style" type="hv:styleList" form="unqualified" />
       <xs:attribute name="context-key" type="xs:token" />
       <xs:attribute name="axis">
         <xs:simpleType>
@@ -23,9 +35,9 @@
           </xs:restriction>
         </xs:simpleType>
       </xs:attribute>
-      <xs:attribute name="distance" type="xs:nonNegativeInteger" />
       <xs:attribute name="duration" type="xs:nonNegativeInteger" />
-      <xs:attribute name="initial-opacity" type="xs:nonNegativeInteger" />
+      <xs:attribute name="scroll-range" type="scroll-opacity:scroll-range" />
+      <xs:attribute name="opacity-range" type="scroll-opacity:opacity-range" />
     </xs:complexType>
   </xs:element>
 </xs:schema>
diff --git a/demo/src/Components/ScrollOpacity/ScrollOpacity.tsx b/demo/src/Components/ScrollOpacity/ScrollOpacity.tsx
index 84b18897a..21444539c 100644
--- a/demo/src/Components/ScrollOpacity/ScrollOpacity.tsx
+++ b/demo/src/Components/ScrollOpacity/ScrollOpacity.tsx
@@ -1,16 +1,28 @@
 import type { HvComponentProps, LocalName } from 'hyperview';
 import Hyperview, { useScrollContext } from 'hyperview';
 import React, { useEffect, useRef } from 'react';
-import { getNumberAttr, namespaceURI } from './utils';
+import {
+  calculateOpacity,
+  getNumberAttr,
+  getRangeAttr,
+  namespaceURI,
+} from './utils';
 import { Animated } from 'react-native';
 
 const ScrollOpacity = (props: HvComponentProps) => {
   const contextKey = props.element.getAttributeNS(namespaceURI, 'context-key');
   const axis = props.element.getAttributeNS(namespaceURI, 'axis') || 'vertical';
-  const distance = getNumberAttr(props.element, 'distance', 0);
+  const scrollRange = getRangeAttr(props.element, 'scroll-range', [0, 100]);
+  const opacityRange = getRangeAttr(props.element, 'opacity-range', [0, 1]);
   const duration = getNumberAttr(props.element, 'duration', 0);
-  const initialOpacity = getNumberAttr(props.element, 'initial-opacity', 1);
-  const opacity = useRef(new Animated.Value(initialOpacity)).current;
+  const style = Hyperview.createStyleProp(props.element, props.stylesheets, {
+    ...props.options,
+    styleAttr: 'style',
+  });
+  const opacity = useRef(
+    // Assumes the default scroll position is 0
+    new Animated.Value(calculateOpacity(0, scrollRange, opacityRange)),
+  ).current;
   const { offsets } = useScrollContext();
 
   const defaultPosition = { x: 0, y: 0 };
@@ -34,16 +46,13 @@ const ScrollOpacity = (props: HvComponentProps) => {
   })();
 
   useEffect(() => {
-    const toValue = Math.min(
-      Math.max(position / Math.max(distance, 2) - 1, 0),
-      1,
-    );
+    const toValue = calculateOpacity(position, scrollRange, opacityRange);
     Animated.timing(opacity, {
       duration,
       toValue,
       useNativeDriver: true,
     }).start();
-  }, [axis, contextKey, distance, duration, opacity, position, x, y]);
+  }, [duration, opacity, opacityRange, position, scrollRange]);
 
   const children = (Hyperview.renderChildren(
     props.element,
@@ -51,7 +60,7 @@ const ScrollOpacity = (props: HvComponentProps) => {
     props.onUpdate,
     props.options,
   ) as unknown) as JSX.Element;
-  return <Animated.View style={{ opacity }}>{children}</Animated.View>;
+  return <Animated.View style={[style, { opacity }]}>{children}</Animated.View>;
 };
 
 ScrollOpacity.namespaceURI = namespaceURI;
diff --git a/demo/src/Components/ScrollOpacity/utils.ts b/demo/src/Components/ScrollOpacity/utils.ts
index 24aa11837..31d662060 100644
--- a/demo/src/Components/ScrollOpacity/utils.ts
+++ b/demo/src/Components/ScrollOpacity/utils.ts
@@ -3,11 +3,55 @@ export const namespaceURI = 'https://hyperview.org/scroll-opacity';
 export const getNumberAttr = (
   element: Element,
   attrName: string,
-  defaultValue: number | null = null,
+  defaultValue: number,
 ): number => {
   const value: string | null = element.getAttributeNS(namespaceURI, attrName);
   if (!value) {
-    return defaultValue || 0;
+    return defaultValue;
   }
   return parseInt(value, 10);
 };
+
+export const getRangeAttr = (
+  element: Element,
+  attrName: string,
+  defaultValue: [number, number],
+): [number, number] => {
+  const value: string | null = element.getAttributeNS(namespaceURI, attrName);
+  if (!value) {
+    return defaultValue;
+  }
+  try {
+    return JSON.parse(value);
+  } catch (e) {
+    throw new Error(`Invalid range attribute: ${value}`);
+  }
+};
+
+export const calculateOpacity = (
+  position: number,
+  inputRange: [number, number],
+  outputRange: [number, number],
+) => {
+  const [a, b] = inputRange;
+  const [c, d] = outputRange;
+
+  // Check if the input range has the same start and end values
+  if (a === b) {
+    // If the position is exactly at a (or b), return the start of the output range
+    // Otherwise, return the end of the output range
+    return position <= a ? c : d;
+  }
+
+  // Calculate the slope
+  const slope = (d - c) / (b - a);
+
+  // Calculate the opacity based on the position
+  if (position < a) {
+    return c;
+  }
+  if (position > b) {
+    return d;
+  }
+  return c + slope * (position - a);
+};


Reviews:
user: login: hgray-instawork
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-05T18:18:54Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T18:57:34Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T18:59:26Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:01:24Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:02:00Z
user: login: hgray-instawork
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:02:38Z
user: login: hgray-instawork
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T19:13:28Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T20:05:41Z
user: login: flochtililoch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-05T20:15:30Z

Review comments:
diff_hunk: @@ -23,9 +30,9 @@
           </xs:restriction>
         </xs:simpleType>
       </xs:attribute>
-      <xs:attribute name="distance" type="xs:nonNegativeInteger" />
       <xs:attribute name="duration" type="xs:nonNegativeInteger" />
-      <xs:attribute name="initial-opacity" type="xs:nonNegativeInteger" />
+      <xs:attribute name="scroll-range" type="scroll-opacity:range" />
+      <xs:attribute name="opacity-range" type="scroll-opacity:range" />
path: demo/schema/scroll-opacity.xsd
user: login: hgray-instawork
body: Do you want to create another simple type with a 0-1 restriction rather than re-using the `range`? It may be confusing to devs who might expect a 0-100 range for opacity.
created_at: 2025-03-05T17:54:13Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,28 @@
 import type { HvComponentProps, LocalName } from 'hyperview';
 import Hyperview, { useScrollContext } from 'hyperview';
 import React, { useEffect, useRef } from 'react';
-import { getNumberAttr, namespaceURI } from './utils';
+import {
+  calculateOpacity,
+  getNumberAttr,
+  getRangeAttr,
+  namespaceURI,
+} from './utils';
 import { Animated } from 'react-native';
 
 const ScrollOpacity = (props: HvComponentProps) => {
   const contextKey = props.element.getAttributeNS(namespaceURI, 'context-key');
   const axis = props.element.getAttributeNS(namespaceURI, 'axis') || 'vertical';
-  const distance = getNumberAttr(props.element, 'distance', 0);
+  const scrollRange = getRangeAttr(props.element, 'scroll-range', [0, 100]);
+  const opacityRange = getRangeAttr(props.element, 'opacity-range', [0, 1]);
   const duration = getNumberAttr(props.element, 'duration', 0);
-  const initialOpacity = getNumberAttr(props.element, 'initial-opacity', 1);
-  const opacity = useRef(new Animated.Value(initialOpacity)).current;
+  const style = Hyperview.createStyleProp(props.element, props.stylesheets, {
+    ...props.options,
+    styleAttr: 'style',
+  });
+  const opacity = useRef(
+    // Assumes the default scroll position is 0
path: demo/src/Components/ScrollOpacity/ScrollOpacity.tsx
user: login: hgray-instawork
body: Would this work with a scroll range which defines a non-zero start?
created_at: 2025-03-05T17:57:12Z
author_association: COLLABORATOR
diff_hunk: @@ -3,17 +3,24 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
path: demo/schema/scroll-opacity.xsd
user: login: hgray-instawork
body: Can we assert the second number is higher than the first? Otherwise you could have a divide by zero error in `calculateOpacity`
created_at: 2025-03-05T18:01:02Z
author_association: COLLABORATOR
diff_hunk: @@ -3,11 +3,48 @@ export const namespaceURI = 'https://hyperview.org/scroll-opacity';
 export const getNumberAttr = (
   element: Element,
   attrName: string,
-  defaultValue: number | null = null,
+  defaultValue: number,
 ): number => {
   const value: string | null = element.getAttributeNS(namespaceURI, attrName);
   if (!value) {
-    return defaultValue || 0;
+    return defaultValue;
   }
   return parseInt(value, 10);
 };
+
+export const getRangeAttr = (
+  element: Element,
+  attrName: string,
+  defaultValue: [number, number],
+): [number, number] => {
+  const value: string | null = element.getAttributeNS(namespaceURI, attrName);
+  if (!value) {
+    return defaultValue;
+  }
+  try {
+    return JSON.parse(value);
+  } catch (e) {
+    throw new Error(`Invalid range attribute: ${value}`);
+  }
+};
+
+export const calculateOpacity = (
+  position: number,
+  inputRange: [number, number],
+  outputRange: [number, number],
+) => {
+  const [a, b] = inputRange;
path: demo/src/Components/ScrollOpacity/utils.ts
user: login: hgray-instawork
body: Should you exit early if the values are the same?
created_at: 2025-03-05T18:01:51Z
author_association: COLLABORATOR
diff_hunk: @@ -3,17 +3,24 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
path: demo/schema/scroll-opacity.xsd
user: login: flochtililoch
body: good catch, I updated the implementation to cover this case 
created_at: 2025-03-05T18:57:33Z
author_association: COLLABORATOR
diff_hunk: @@ -3,11 +3,48 @@ export const namespaceURI = 'https://hyperview.org/scroll-opacity';
 export const getNumberAttr = (
   element: Element,
   attrName: string,
-  defaultValue: number | null = null,
+  defaultValue: number,
 ): number => {
   const value: string | null = element.getAttributeNS(namespaceURI, attrName);
   if (!value) {
-    return defaultValue || 0;
+    return defaultValue;
   }
   return parseInt(value, 10);
 };
+
+export const getRangeAttr = (
+  element: Element,
+  attrName: string,
+  defaultValue: [number, number],
+): [number, number] => {
+  const value: string | null = element.getAttributeNS(namespaceURI, attrName);
+  if (!value) {
+    return defaultValue;
+  }
+  try {
+    return JSON.parse(value);
+  } catch (e) {
+    throw new Error(`Invalid range attribute: ${value}`);
+  }
+};
+
+export const calculateOpacity = (
+  position: number,
+  inputRange: [number, number],
+  outputRange: [number, number],
+) => {
+  const [a, b] = inputRange;
path: demo/src/Components/ScrollOpacity/utils.ts
user: login: flochtililoch
body: this is now handled line 40
created_at: 2025-03-05T18:59:26Z
author_association: COLLABORATOR
diff_hunk: @@ -1,16 +1,28 @@
 import type { HvComponentProps, LocalName } from 'hyperview';
 import Hyperview, { useScrollContext } from 'hyperview';
 import React, { useEffect, useRef } from 'react';
-import { getNumberAttr, namespaceURI } from './utils';
+import {
+  calculateOpacity,
+  getNumberAttr,
+  getRangeAttr,
+  namespaceURI,
+} from './utils';
 import { Animated } from 'react-native';
 
 const ScrollOpacity = (props: HvComponentProps) => {
   const contextKey = props.element.getAttributeNS(namespaceURI, 'context-key');
   const axis = props.element.getAttributeNS(namespaceURI, 'axis') || 'vertical';
-  const distance = getNumberAttr(props.element, 'distance', 0);
+  const scrollRange = getRangeAttr(props.element, 'scroll-range', [0, 100]);
+  const opacityRange = getRangeAttr(props.element, 'opacity-range', [0, 1]);
   const duration = getNumberAttr(props.element, 'duration', 0);
-  const initialOpacity = getNumberAttr(props.element, 'initial-opacity', 1);
-  const opacity = useRef(new Animated.Value(initialOpacity)).current;
+  const style = Hyperview.createStyleProp(props.element, props.stylesheets, {
+    ...props.options,
+    styleAttr: 'style',
+  });
+  const opacity = useRef(
+    // Assumes the default scroll position is 0
path: demo/src/Components/ScrollOpacity/ScrollOpacity.tsx
user: login: flochtililoch
body: yes - one example covers it (the "title" that appears as a starting scroll value of 30)
created_at: 2025-03-05T19:01:24Z
author_association: COLLABORATOR
diff_hunk: @@ -23,9 +30,9 @@
           </xs:restriction>
         </xs:simpleType>
       </xs:attribute>
-      <xs:attribute name="distance" type="xs:nonNegativeInteger" />
       <xs:attribute name="duration" type="xs:nonNegativeInteger" />
-      <xs:attribute name="initial-opacity" type="xs:nonNegativeInteger" />
+      <xs:attribute name="scroll-range" type="scroll-opacity:range" />
+      <xs:attribute name="opacity-range" type="scroll-opacity:range" />
path: demo/schema/scroll-opacity.xsd
user: login: flochtililoch
body: makes sense, I'll update
created_at: 2025-03-05T19:02:00Z
author_association: COLLABORATOR
diff_hunk: @@ -3,17 +3,24 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
path: demo/schema/scroll-opacity.xsd
user: login: hgray-instawork
body: Is it worth asserting in the XML too?
created_at: 2025-03-05T19:02:38Z
author_association: COLLABORATOR
diff_hunk: @@ -3,17 +3,24 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
path: demo/schema/scroll-opacity.xsd
user: login: hgray-instawork
body: Looks pretty gnarly, I think we can skip it for now since you're protecting against bad data.
created_at: 2025-03-05T19:13:28Z
author_association: COLLABORATOR
diff_hunk: @@ -23,9 +30,9 @@
           </xs:restriction>
         </xs:simpleType>
       </xs:attribute>
-      <xs:attribute name="distance" type="xs:nonNegativeInteger" />
       <xs:attribute name="duration" type="xs:nonNegativeInteger" />
-      <xs:attribute name="initial-opacity" type="xs:nonNegativeInteger" />
+      <xs:attribute name="scroll-range" type="scroll-opacity:range" />
+      <xs:attribute name="opacity-range" type="scroll-opacity:range" />
path: demo/schema/scroll-opacity.xsd
user: login: flochtililoch
body: 4e021ce4cf41918a1a6b23ad587e22f3f077ab40
created_at: 2025-03-05T20:05:41Z
author_association: COLLABORATOR
diff_hunk: @@ -3,17 +3,24 @@
   attributeFormDefault="qualified"
   elementFormDefault="qualified"
   targetNamespace="https://hyperview.org/scroll-opacity"
+  xmlns:scroll-opacity="https://hyperview.org/scroll-opacity"
   xmlns:hv="https://hyperview.org/hyperview"
 >
   <xs:import
     namespace="https://hyperview.org/hyperview"
     schemaLocation="hyperview.xsd"
   />
+  <xs:simpleType name="range">
+    <xs:restriction base="xs:string">
+      <xs:pattern value="\[-?[0-9]+,\s?-?[0-9]+\]" />
path: demo/schema/scroll-opacity.xsd
user: login: flochtililoch
body: actually there's a use case for the range to have the same start/end values (at least for the scroll range), so that there's no "slope" to apply to the calculated opacity. My assertion in the code just checks whether if the two numbers are equal and returns early.
created_at: 2025-03-05T20:15:30Z
author_association: COLLABORATOR

PR comments:
user: login: adamstep
created_at: 2025-03-05T18:33:21Z
body: @flochtililoch can the mapping of the scroll position to opacity be done using Animated interpolate helper? https://reactnative.dev/docs/animatedvalue#interpolate
author_association: CONTRIBUTOR
user: login: flochtililoch
created_at: 2025-03-05T19:04:24Z
body: > @flochtililoch can the mapping of the scroll position to opacity be done using Animated interpolate helper? https://reactnative.dev/docs/animatedvalue#interpolate

That was my original attempt, but the value that needs to be interpolated with these range is a scroll position, which comes from the context. I believe the `interpolate` method to be only available on `Animated.Value`, which I think would make it a bit more challenging. Let me know if you think there's another way to use it without maintaining an additional state/value in the component.
author_association: COLLABORATOR
user: login: adamstep
created_at: 2025-03-05T21:04:21Z
body: > > @flochtililoch can the mapping of the scroll position to opacity be done using Animated interpolate helper? https://reactnative.dev/docs/animatedvalue#interpolate
> 
> That was my original attempt, but the value that needs to be interpolated with these range is a scroll position, which comes from the context. I believe the `interpolate` method to be only available on `Animated.Value`, which I think would make it a bit more challenging. Let me know if you think there's another way to use it without maintaining an additional state/value in the component.

Ah that makes sense, yes it only works on `Animated.Value`.
author_association: CONTRIBUTOR