Skip to main content

Create A WebView-free Blog App with React Native Render HTML, Part III

· 13 min read
Jules Sam. Randolph
Developer of React Native Render HTML v6

This article is the part III of the Create a WebView-free Blog App with React Native Render HTML serie. See also Part I and Part II.

tip

The source code of this case study is available in the main branch of this repo: jsamr/rnrh-blog. The enhanced branch contains a few more features beyond this tutorial, such as a refined UI, dark mode, caching with react-queries... etc. You can try out the enhanced version right now with expo, see the project page for instructions.

tip

If you have any question or remarks regarding this tutorial, you're welcome in our Discord channel.

Tap To Scroll Feature#

The Scroller Class#

We'll put all the scrolling logic in a Scroller class that we'll later use with hooks. Create this new file: utils/Scroller.ts:

utils/Scroller.ts
import { MutableRefObject } from "react";
import { LayoutChangeEvent, Platform, ScrollViewProps } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { EventEmitter } from "events";
// This is the min distance from the top edge of the scroll view
// to select a heading
const MIN_DIST_FROM_TOP_EDG = 15;
export default class Scroller {
private ref: MutableRefObject<ScrollView | null>;
private entriesMap: Record<string, number> = {};
private entriesCoordinates: Array<[string, number]> = [];
private eventEmitter = new EventEmitter();
private lastEntryName = "";
private offset = 0;
constructor(ref: MutableRefObject<ScrollView | null>) {
this.ref = ref;
}
handlers: ScrollViewProps = {
onContentSizeChange: () => {
this.entriesCoordinates = Object.entries(this.entriesMap).sort(
(a, b) => a[1] - b[1]
);
},
onScroll: ({ nativeEvent }) => {
const offsetY =
nativeEvent.contentOffset.y - this.offset + MIN_DIST_FROM_TOP_EDG;
const layoutHeight = nativeEvent.layoutMeasurement.height;
// We use a conditional to avoid overheading the JS thread on Android.
// On iOS, scrollEventThrottle will do the work.
if (Platform.OS !== "android" || Math.abs(nativeEvent.velocity!.y) < 1) {
for (let i = 0; i < this.entriesCoordinates.length; i++) {
const [entryName, lowerBound] = this.entriesCoordinates[i];
const upperBound =
i < this.entriesCoordinates.length - 1
? this.entriesCoordinates[i + 1][1]
: lowerBound + layoutHeight;
if (offsetY >= lowerBound && offsetY < upperBound) {
if (entryName !== this.lastEntryName) {
this.eventEmitter.emit("select-entry", entryName);
this.lastEntryName = entryName;
}
break;
}
}
}
},
};
setOffset(offset: number) {
this.offset = offset;
}
addSelectedEntryListener(listener: (entryName: string) => void) {
this.eventEmitter.addListener("select-entry", listener);
}
removeSelectedEntryListener(listener: (entryName: string) => void) {
this.eventEmitter.removeListener("select-entry", listener);
}
registerScrollEntry(name: string, layout: LayoutChangeEvent) {
this.entriesMap[name] = layout.nativeEvent.layout.y;
}
scrollToEntry(entryName: string) {
if (entryName in this.entriesMap) {
this.ref.current?.scrollTo({
y: this.entriesMap[entryName] + this.offset - MIN_DIST_FROM_TOP_EDG,
animated: true,
});
}
}
}

Below is a summary of each member in this class.

constructor

The constructor takes a ScrollView ref to enable the scrollToEntry method.

addSelectedEntryListener

A method to listen to selected entry changes. This will be useful in the table of content drawer to update the active entry on scroll.

removeSelectedEntryListener

A method to free a listener to selected entry changes.

registerScrollEntry

A method to be used with onLayout in order to store the coordinates of each entry in the body of the article.

handlers

Event handlers to be passed to a ScrollView component. The onScroll handler will be used to update the selected entry in the table of content drawer.

setOffset

A method to set the offset of the headings container. Because of the DOM structure offered by Docusaurus wich looks like:

<article>
<header>...</header>
<div class="markdown">
<h2>...</h2>
<h3>...</h3>
</div>
</article>

the computed headings tags coordinates will be relative to the <div> rather then relative to the ScrollView content, and we need to adjust to that.

scrollToEntry

A method to imperatively scroll to the given entry name.

Sharing the Scroller in a React Context#

Let's start by creating a scroller context and export the relevant hook and provider:

utils/scroller.tsx
import React, { createContext, PropsWithChildren, useContext } from "react";
import Scroller from './Scroller';
const scrollerContext = createContext<Scroller>(null as any);
export function useScroller(): Scroller {
return useContext(scrollerContext);
}
export function ScrollerProvider({
children,
scroller
}: PropsWithChildren<{ scroller: Scroller }>) {
return (
<scrollerContext.Provider value={scroller}>
{children}
</scrollerContext.Provider>
);
}

Then we can provide a scroller instance from the ArticleScreen component, and scroll to the targeted entry on menu entry press.

screens/ArticleScreen.tsx
import React, { useCallback, useEffect, useRef, useMemo } from "react";
// ... other imports
import Scroller from "../utils/Scroller";
import { ScrollerProvider } from "../utils/scroller";
// other hooks
function useScrollFeature(scrollerDep: any) {
const scrollViewRef = useRef<null | ScrollView>(null);
const scroller = useMemo(() => new Scroller(scrollViewRef), [scrollerDep]);
return {
scroller,
scrollViewRef,
};
}
export default function ArticleScreen(props: ArticleScreenProps) {
useSetTitleEffect(props);
const url = props.route.params.url;
const { dom, headings } = useArticleDom(url);
const { drawerRef, openDrawer, closeDrawer } = useDrawer();
const { scrollViewRef, scroller } = useScrollFeature(url);
const onPressEntry = useCallback((entry: string) => {
closeDrawer();
scroller.scrollToEntry(entry);
}, [scroller]);
const renderToc = useCallback(
function renderToc() {
return <TOC headings={headings} onPressEntry={onPressEntry} />;
},
[headings]
);
return (
<ScrollerProvider scroller={scroller}>
<DrawerLayout
drawerPosition="right"
drawerWidth={300}
renderNavigationView={renderToc}
ref={drawerRef}
>
<ArticleBody scrollViewRef={scrollViewRef} dom={dom} />
<FAB
style={styles.fab}
color="#61dafb"
icon="format-list-bulleted-square"
onPress={openDrawer}
/>
</DrawerLayout>
</ScrollerProvider>
);
}
// styles

Finally, we must consume the scrollViewRef in the ArticleBody component, and pass the Scroller.handlers event handlers to the ScrollView component:

components/ArticleBody.tsx
import React, { useCallback } from "react";
// ... other imports
import { useScroller } from "../utils/scroller";
// other definitions
export default function ArticleBody({
dom,
scrollViewRef
}: {
dom: Document | null;
scrollViewRef: any;
}) {
const { width } = useWindowDimensions();
const availableWidth = Math.min(width, 500);
const scroller = useScroller();
return (
<ScrollView
{...scroller.handlers}
style={styles.container}
ref={scrollViewRef}
scrollEventThrottle={100}
// other props
>
{/* ... */}
</ScrollView>
);
}

Great! Nevertheless we have yet two unaddressed issues:

  • Update selected entry on scroll in the TOC;
  • Register headings layouts. We will use a custom renderer for that purpose.

Listening to Entry Changes in TOC#

First of all, I propose to factor the logic of adding a listener to selected entry changes in a separate hook (hooks/useOnEntryChangeEffect.ts):

hooks/useOnEntryChangeEffect.ts
import { useEffect } from "react";
import { useScroller } from "../utils/scroller";
export default function useOnEntryChangeEffect(
onEntryChange: (entryName: string) => void
) {
const scroller = useScroller();
useEffect(
function updateActiveTargetOnScroll() {
scroller.addSelectedEntryListener(onEntryChange);
return () => scroller.removeSelectedEntryListener(onEntryChange);
},
[scroller, onEntryChange]
);
}

Then, we just need to consume this hook from the TOC component:

components/TOC.tsx
// ...other imports
import useOnEntryChangeEffect from "../hooks/useOnEntryChangeEffect";
export default function TOC({
headings,
onPressEntry,
}: {
headings: Element[];
onPressEntry?: (name: string) => void;
}) {
const [activeEntry, setActiveEntry] = useState(
headings.length ? textContent(headings[0]) : ""
);
useOnEntryChangeEffect(setActiveEntry);
// ...
}

Register Headings Layouts#

The Scroller is still missing the coordinates of each heading to be able to properly scrollToEntry. For this purpose, we are going to create a custom renderer for <h2> and <h3> tags. We will also need to register a <header> renderer to store the header height. If you remember well, the DOM has a structure like below:

<article>
<!-- We need to account for the header height -->
<header>...</header>
<div class="markdown">
<h2>...</h2>
<h3>...</h3>
</div>
</article>

Let's get back to components/WebEngine.tsx and register both renderers here:

components/WebEngine.tsx
import React, { useCallback } from "react";
import {
CustomBlockRenderer,
CustomTagRendererRecord,
RenderHTMLConfigProvider,
TRenderEngineProvider,
TRenderEngineConfig,
} from "react-native-render-html";
import { findOne, textContent } from "domutils";
import { useScroller } from "../utils/scroller";
import { LayoutChangeEvent } from "react-native";
const HeadingRenderer: CustomBlockRenderer = function HeaderRenderer({
TDefaultRenderer,
...props
}) {
const scroller = useScroller();
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
scroller.registerScrollEntry(textContent(props.tnode.domNode!), e);
},
[scroller]
);
return <TDefaultRenderer {...props} viewProps={{ onLayout }} />;
};
const HeaderRenderer: CustomBlockRenderer = function HeaderRenderer({
TDefaultRenderer,
...props
}) {
const scroller = useScroller();
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
scroller.setOffset(e.nativeEvent.layout.y + e.nativeEvent.layout.height);
},
[scroller]
);
return <TDefaultRenderer {...props} viewProps={{ onLayout }} />;
};
const renderers: CustomTagRendererRecord = {
h2: HeadingRenderer,
h3: HeadingRenderer,
header: HeaderRenderer,
};
const selectDomRoot: TRenderEngineConfig["selectDomRoot"] = (node) =>
findOne((e) => e.name === "article", node.children, true);
const ignoredDomTags = ["button", "footer"];
export default function WebEngine({ children }: React.PropsWithChildren<{}>) {
// Every prop is defined outside of the function component.
// This is important to prevent extraneous rebuilts of the engine.
return (
<TRenderEngineProvider
ignoredDomTags={ignoredDomTags}
selectDomRoot={selectDomRoot}
>
<RenderHTMLConfigProvider
renderers={renderers}
enableExperimentalMarginCollapsing
>
{children}
</RenderHTMLConfigProvider>
</TRenderEngineProvider>
);
}

Because the <h2>, <h3> and <header> tags have a content model set to block, they will be rendered in a View, so we can pass onLayout in ​viewProps prop.

Hence we're done with the tap-to-scroll feature! But the ArticleBody is still pretty ugly, so we'll use some styles and fixes to prettify it!

Styling Refinements#

Fixing the Avatar#

The avatar should be 50x50 and its container displayed in row. We are going to fix it in two steps:

  1. By targeting the container class with styles to display in row;
  2. By setting a custom <img> renderer to fix the size.

So let's edit the components/WebEngine to apply those fixes:

components/WebEngine
import React, { useCallback } from "react";
import {
CustomBlockRenderer,
CustomTagRendererRecord,
MixedStyleRecord,
RenderHTMLConfigProvider,
TRenderEngineProvider,
TRenderEngineConfig,
useInternalRenderer,
} from "react-native-render-html";
// ... other imports
// HeaderRenderer
const ImageRenderer: CustomBlockRenderer = function ImageRenderer(props) {
const { Renderer, rendererProps } = useInternalRenderer("img", props);
if (props.tnode.parent?.hasClass("avatar__photo")) {
return <Renderer {...rendererProps} width={50} height={50} />;
}
return <Renderer {...rendererProps} />;
};
const renderers: CustomTagRendererRecord = {
h2: HeadingRenderer,
h3: HeadingRenderer,
img: ImageRenderer
};
// ... other definitions
const classesStyles: MixedStyleRecord = {
avatar: {
marginTop: 10,
flexDirection: "row",
alignItems: "center",
flexWrap: "nowrap",
},
avatar__photo: {
width: 50,
height: 50,
borderRadius: 25,
overflow: "hidden",
},
avatar__intro: {
flexShrink: 1,
alignItems: "flex-start",
},
avatar__name: {
fontWeight: "bold",
flexGrow: 0,
marginBottom: 10,
},
avatar__subtitle: {
color: "rgb(118, 118, 118)",
fontWeight: "bold",
lineHeight: 16,
},
"avatar__photo-link": {
borderRadius: 25,
marginRight: 10,
overflow: "hidden",
},
}
export default function WebEngine({ children }: React.PropsWithChildren<{}>) {
return (
<TRenderEngineProvider
ignoredDomTags={ignoredDomTags}
selectDomRoot={selectDomRoot}
classesStyles={classesStyles}
// other props
>
{/*...*/}
</TRenderEngineProvider>
);
}

Fixing Paragraphs in <li> Elements#

Paragraphs nested in <li> elements have top and bottom margins, which is undesirable. To fix the issue, we're going to add a custom <p> renderer like so:

components/WebEngine.tsx
// renderers and imports
const ParagraphRenderer: CustomBlockRenderer = function ParagraphRenderer({
TDefaultRenderer,
tnode,
...props
}) {
const marginsFix =
tnode.markers.olNestLevel > -1 || tnode.markers.ulNestLevel > -1
? { marginTop: 0, marginBottom: 0 }
: null;
return (
<TDefaultRenderer
{...props}
tnode={tnode}
style={[props.style, marginsFix]}
/>
);
};
const renderers: CustomTagRendererRecord = {
// ... other renderers
p: ParagraphRenderer
};
note

We are using markers which contain the current nest level of ol and ul elements to assess if we are rendering inside a list. See Markers.

Discard # anchors appended to Headings by Docusaurus#

These elements have a "hash-link" class, so we can use ignoreDomNode to discard them:

components/WebEngine.tsx
import React, { useCallback } from "react";
import {
CustomBlockRenderer,
CustomTagRendererRecord,
isDomElement,
MixedStyleRecord,
RenderHTMLConfigProvider,
TRenderEngineProvider,
TRenderEngineConfig,
useInternalRenderer,
} from "react-native-render-html";
// ...
const ignoreDomNode: TRenderEngineConfig["ignoreDomNode"] = (node) =>
isDomElement(node) && !!node.attribs.class?.match(/hash-link/);
export default function WebEngine({ children }: React.PropsWithChildren<{}>) {
// Every prop is defined outside of the function component.
// This is important to prevent extraneous rebuilts of the engine.
return (
<TRenderEngineProvider
ignoredDomTags={ignoredDomTags}
selectDomRoot={selectDomRoot}
ignoreDomNode={ignoreDomNode}
// ...
>
{/*...*/}
</TRenderEngineProvider>
);
}

Great! Now the # characters have been removed:

However, code samples look pretty ugly:

  • They're missing padding;
  • They should be horizontally scrollable and lines should not wrap;
  • A monospace font should be used;
  • Whitespaces should be preserved.

So, let's fix it!

Fixing Code Samples#

Code samples are rendered by Docusaurus in a <pre class="prism-code"> tag. We need to fix two issues:

  • Define a custom renderer for pre tags, which renders inside a ScrollView when matching the "prism-code" class.
  • Define a custom renderer for span tags. We need to do that because the whole code block is rendered inside a code element with a display: flex; flex-direction: column. However, code is translated to a React Native Text since his element model is textual. To work around this issue, we can inject line breaks after each span element with a CSS token-line class which content does not end with a new line.
components/WebEngine.tsx
// ...other imports
import { TChildrenRenderer } from 'react-native-render-html';
import { ScrollView } from 'react-native-gesture-handler';
// ...
const PreRenderer: CustomBlockRenderer = function PreRenderer({
TDefaultRenderer,
...props
}) {
if (props.tnode.hasClass("prism-code")) {
return (
<ScrollView horizontal style={props.style}>
<TDefaultRenderer
{...props}
style={{ flexGrow: 1, flexShrink: 1, padding: 10 }}
/>
</ScrollView>
);
}
return <TDefaultRenderer {...props} />;
};
function tnodeEndsWithNewLine(tnode: TNode): boolean {
if (tnode.type === "text") {
return tnode.data.endsWith("\n");
}
const lastChild = tnode.children[tnode.children.length - 1];
return lastChild ? tnodeEndsWithNewLine(lastChild) : false;
}
const SpanRenderer: CustomTextualRenderer = function SpanRenderer({
TDefaultRenderer,
...props
}) {
if (props.tnode.hasClass("token-line") && !tnodeEndsWithNewLine(props.tnode)) {
return (
<TDefaultRenderer {...props}>
<TChildrenRenderer tchildren={props.tnode.children} />
{"\n"}
</TDefaultRenderer>
);
}
return <TDefaultRenderer {...props} />;
};
const renderers: CustomTagRendererRecord = {
// ... other renderers
span: SpanRenderer,
pre: PreRenderer,
};
const classesStyles: MixedStyleRecord = {
// ... other classes styles
"prism-code": {
backgroundColor: "#282c34",
fontFamily: "monospace",
borderRadius: 10,
fontSize: 14,
lineHeight: 14 * 1.6,
flexShrink: 0,
},
};

That's looking much better. We're almost done!

Final Touch#

We could add a few more styles to match the React Native blog styles:

components/WebEngine.tsx
import {
// ...
MixedStyleDeclaration,
// ...
} from "react-native-render-html";
// other imports and declarations
const tagsStyles: MixedStyleRecord = {
a: {
color: "#1c1e21",
backgroundColor: "rgba(187, 239, 253, 0.3)",
},
li: {
marginBottom: 10,
},
img: {
alignSelf: "center",
},
h4: {
marginBottom: 0,
marginTop: 0,
},
code: {
backgroundColor: "rgba(0, 0, 0, 0.06)",
fontSize: 14,
},
blockquote: {
marginLeft: 0,
marginRight: 0,
paddingLeft: 20,
paddingRight: 20,
backgroundColor: "#fff8d8",
borderLeftWidth: 10,
borderLeftColor: "#ffe564",
},
};
const baseStyle: MixedStyleDeclaration = {
color: "#1c1e21",
fontSize: 16,
lineHeight: 16 * 1.8,
};
export default function WebEngine({ children }: React.PropsWithChildren<{}>) {
return (
<TRenderEngineProvider
// ...
tagsStyles={tagsStyles}
baseStyle={baseStyle}>
{/*...*/}
</TRenderEngineProvider>
);
}

Epilogue#

Frustrating React Native Text Limitations#

As a final note, I'd like to mention a few frustrating limitations in React Native that prevented me from replicating more accurately the official blog styles:

  1. backgroundColor spans to the full line-box of text elements, whereas in CSS, it only spans to the text content-area. Below is a diagram explaining the difference: See a complete explanation in this excellent article on CSS text styling.
  2. padding and border are ignored in nested text elements.

All these features are required to get the official blog appealing anchors styles:

Instead we have backgrounds overlapping each other, which becomes weird when there is a high density of anchors:

This is because, as stated before, the backgroundColor spans to the entire height of the line box, instead of spanning to the content area.

Going Further#

You can take a look at the enhanced branch of the project and see how the below features have been implemented:

  • Cached queries with react-queries;
  • Dark mode (follows system mode);
  • Progressive rendering for fast time to first contentful paint via FlatList;
  • Collapsible header with react-native-reanimated (v2);
  • Video support with expo-av.

That's all for this tutorial! Don't forget to give us a star if you enjoy this library. You can also follow me on Twitter, and rate this library on Open Base.