├── .gitignore
├── App.tsx
├── README.md
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── example.gif
├── package.json
├── src
├── components
│ ├── ConnectionItem.tsx
│ ├── ConnectionList.tsx
│ ├── Header.tsx
│ ├── HeaderOverlay.tsx
│ └── TabBar.tsx
├── hooks
│ └── useScrollSync.ts
├── mocks
│ └── connections.ts
├── screens
│ └── Profile.tsx
└── types
│ ├── Connection.ts
│ ├── HeaderConfig.ts
│ ├── ScrollPair.ts
│ └── Visibility.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .expo
3 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationContainer } from "@react-navigation/native";
2 | import React, { FC } from "react";
3 | import { StyleSheet } from "react-native";
4 | import { SafeAreaProvider } from "react-native-safe-area-context";
5 | import Connection from "./src/screens/Profile";
6 |
7 | const App: FC = () => (
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
15 | const styles = StyleSheet.create({
16 | container: {
17 | flex: 1,
18 | backgroundColor: "white",
19 | },
20 | });
21 |
22 | export default App;
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-native-collapsing-tab-header
2 |
3 | Implementation of a Twitter/Instagram-like profile screen featuring swipable tabs and collapsing header.
4 |
5 | ### Created using:
6 |
7 | - React Native
8 | - React Navigation
9 | - React Native Reanimated
10 |
11 | 
12 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "tab-header-animation-workshop",
4 | "slug": "tab-header-animation-workshop",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "splash": {
9 | "image": "./assets/splash.png",
10 | "resizeMode": "contain",
11 | "backgroundColor": "#ffffff"
12 | },
13 | "updates": {
14 | "fallbackToCacheTimeout": 0
15 | },
16 | "assetBundlePatterns": [
17 | "**/*"
18 | ],
19 | "ios": {
20 | "supportsTablet": true
21 | },
22 | "android": {
23 | "adaptiveIcon": {
24 | "foregroundImage": "./assets/adaptive-icon.png",
25 | "backgroundColor": "#FFFFFF"
26 | }
27 | },
28 | "web": {
29 | "favicon": "./assets/favicon.png"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stormotion-Mobile/react-native-collapsing-tab-header/a7a059d11f874a8a8958ba9f552684e582d3d38c/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stormotion-Mobile/react-native-collapsing-tab-header/a7a059d11f874a8a8958ba9f552684e582d3d38c/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stormotion-Mobile/react-native-collapsing-tab-header/a7a059d11f874a8a8958ba9f552684e582d3d38c/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stormotion-Mobile/react-native-collapsing-tab-header/a7a059d11f874a8a8958ba9f552684e582d3d38c/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | plugins: ['react-native-reanimated/plugin'],
6 | };
7 | };
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stormotion-Mobile/react-native-collapsing-tab-header/a7a059d11f874a8a8958ba9f552684e582d3d38c/example.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web",
8 | "eject": "expo eject"
9 | },
10 | "dependencies": {
11 | "@react-native-community/masked-view": "0.1.10",
12 | "@react-navigation/material-top-tabs": "^5.3.13",
13 | "@react-navigation/native": "^5.9.2",
14 | "expo": "~40.0.0",
15 | "expo-status-bar": "~1.0.3",
16 | "react": "16.13.1",
17 | "react-dom": "16.13.1",
18 | "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz",
19 | "react-native-gesture-handler": "~1.8.0",
20 | "react-native-reanimated": "2.0.0-rc.0",
21 | "react-native-safe-area-context": "3.1.9",
22 | "react-native-screens": "~2.15.2",
23 | "react-native-tab-view": "^2.15.2",
24 | "react-native-web": "~0.13.12"
25 | },
26 | "devDependencies": {
27 | "@babel/core": "~7.9.0",
28 | "@types/react": "~16.9.35",
29 | "@types/react-dom": "~16.9.8",
30 | "@types/react-native": "~0.63.2",
31 | "typescript": "~4.0.0"
32 | },
33 | "private": true
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/ConnectionItem.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useMemo } from "react";
2 | import { Image, StyleSheet, Text, View, ViewProps } from "react-native";
3 | import { Connection } from "../types/Connection";
4 |
5 | export const PHOTO_SIZE = 40;
6 |
7 | type Props = Pick & {
8 | connection: Connection;
9 | };
10 |
11 | const ConnectionItem: FC = ({ style, connection }) => {
12 | const { photo, name } = connection;
13 |
14 | const mergedStyle = useMemo(() => [styles.container, style], [style]);
15 |
16 | return (
17 |
18 |
19 | {name}
20 |
21 | );
22 | };
23 |
24 | const styles = StyleSheet.create({
25 | container: { alignItems: "center", flexDirection: "row", padding: 16 },
26 | image: {
27 | height: PHOTO_SIZE,
28 | width: PHOTO_SIZE,
29 | borderRadius: PHOTO_SIZE / 2,
30 | },
31 | name: {
32 | marginLeft: 8,
33 | fontSize: 15,
34 | fontWeight: "500",
35 | },
36 | });
37 |
38 | export default memo(ConnectionItem);
39 |
--------------------------------------------------------------------------------
/src/components/ConnectionList.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, memo, useCallback } from "react";
2 | import {
3 | FlatList,
4 | FlatListProps,
5 | ListRenderItem,
6 | StyleSheet,
7 | } from "react-native";
8 | import Animated from "react-native-reanimated";
9 | import ConnectionItem from "./ConnectionItem";
10 | import { Connection } from "../types/Connection";
11 |
12 | export const AnimatedFlatList: typeof FlatList = Animated.createAnimatedComponent(
13 | FlatList
14 | );
15 |
16 | type Props = Omit, "renderItem">;
17 |
18 | const ConnectionList = forwardRef((props, ref) => {
19 | const keyExtractor = useCallback((_, index) => index.toString(), []);
20 |
21 | const renderItem = useCallback>(
22 | ({ item }) => ,
23 | []
24 | );
25 |
26 | return (
27 |
34 | );
35 | });
36 |
37 | const styles = StyleSheet.create({
38 | container: {
39 | backgroundColor: "white",
40 | flex: 1,
41 | },
42 | });
43 |
44 | export default memo(ConnectionList);
45 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useMemo } from "react";
2 | import {
3 | Image,
4 | ImageProps,
5 | StyleSheet,
6 | Text,
7 | View,
8 | ViewProps,
9 | } from "react-native";
10 |
11 | export const PHOTO_SIZE = 120;
12 |
13 | type Props = Pick & {
14 | photo: string;
15 | name: string;
16 | bio: string;
17 | };
18 |
19 | const Header: FC = ({ style, name, photo, bio }) => {
20 | const containerStyle = useMemo(() => [styles.container, style], []);
21 |
22 | const photoSource = useMemo(() => ({ uri: photo }), []);
23 |
24 | return (
25 |
26 |
27 |
28 | {name}
29 | {bio}
30 |
31 |
32 | );
33 | };
34 |
35 | const styles = StyleSheet.create({
36 | textContainer: { marginLeft: 24, justifyContent: "center", flex: 1 },
37 | name: { fontSize: 24, fontWeight: "700" },
38 | bio: { fontSize: 15, marginTop: 4 },
39 | photo: {
40 | height: PHOTO_SIZE,
41 | width: PHOTO_SIZE,
42 | borderRadius: PHOTO_SIZE / 2,
43 | },
44 | container: {
45 | flexDirection: "row",
46 | backgroundColor: "white",
47 | padding: 24,
48 | },
49 | });
50 |
51 | export default memo(Header);
52 |
--------------------------------------------------------------------------------
/src/components/HeaderOverlay.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, memo, useMemo } from "react";
2 | import { StyleSheet, Text, View, ViewProps } from "react-native";
3 |
4 | type Props = Pick & { name: string };
5 |
6 | const HeaderOverlay: FC = ({ style, name }) => {
7 | const containerStyle = useMemo(() => [styles.container, style], [style]);
8 |
9 | return (
10 |
11 | {name}
12 |
13 | );
14 | };
15 |
16 | const styles = StyleSheet.create({
17 | container: {
18 | alignItems: "center",
19 | },
20 | title: {
21 | fontSize: 24,
22 | },
23 | });
24 |
25 | export default memo(HeaderOverlay);
26 |
--------------------------------------------------------------------------------
/src/components/TabBar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | MaterialTopTabBar,
3 | MaterialTopTabBarProps,
4 | } from "@react-navigation/material-top-tabs";
5 | import React, { FC, useEffect } from "react";
6 |
7 | type Props = MaterialTopTabBarProps & {
8 | onIndexChange?: (index: number) => void;
9 | };
10 |
11 | const TabBar: FC = ({ onIndexChange, ...props }) => {
12 | const { index } = props.state;
13 |
14 | useEffect(() => {
15 | onIndexChange?.(index);
16 | }, [onIndexChange, index]);
17 |
18 | return ;
19 | };
20 |
21 | export default TabBar;
22 |
--------------------------------------------------------------------------------
/src/hooks/useScrollSync.ts:
--------------------------------------------------------------------------------
1 | import { FlatListProps } from "react-native";
2 | import { HeaderConfig } from "../types/HeaderConfig";
3 | import { ScrollPair } from "../types/ScrollPair";
4 |
5 | const useScrollSync = (
6 | scrollPairs: ScrollPair[],
7 | headerConfig: HeaderConfig
8 | ) => {
9 | const sync: NonNullable["onMomentumScrollEnd"]> = (
10 | event
11 | ) => {
12 | const { y } = event.nativeEvent.contentOffset;
13 |
14 | const { heightCollapsed, heightExpanded } = headerConfig;
15 |
16 | const headerDiff = heightExpanded - heightCollapsed;
17 |
18 | for (const { list, position } of scrollPairs) {
19 | const scrollPosition = position.value ?? 0;
20 |
21 | if (scrollPosition > headerDiff && y > headerDiff) {
22 | continue;
23 | }
24 |
25 | list.current?.scrollToOffset({
26 | offset: Math.min(y, headerDiff),
27 | animated: false,
28 | });
29 | }
30 | };
31 |
32 | return { sync };
33 | };
34 |
35 | export default useScrollSync;
36 |
--------------------------------------------------------------------------------
/src/mocks/connections.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from "../types/Connection";
2 |
3 | export const FRIENDS: Connection[] = [
4 | {
5 | name: "Sophie Brown",
6 | photo: "https://randomuser.me/api/portraits/women/1.jpg",
7 | },
8 | {
9 | name: "William Garcia",
10 | photo: "https://randomuser.me/api/portraits/men/1.jpg",
11 | },
12 | ];
13 |
14 | export const SUGGESTIONS: Connection[] = [
15 | {
16 | name: "Charlotte Jones",
17 | photo: "https://randomuser.me/api/portraits/women/2.jpg",
18 | },
19 | {
20 | name: "Oliver Brown",
21 | photo: "https://randomuser.me/api/portraits/men/2.jpg",
22 | },
23 | {
24 | name: "Jessica Miller",
25 | photo: "https://randomuser.me/api/portraits/women/3.jpg",
26 | },
27 | {
28 | name: "Samuel Johnson",
29 | photo: "https://randomuser.me/api/portraits/men/3.jpg",
30 | },
31 | {
32 | name: "Olivia Martinez",
33 | photo: "https://randomuser.me/api/portraits/women/4.jpg",
34 | },
35 | {
36 | name: "Joshua Miller",
37 | photo: "https://randomuser.me/api/portraits/men/4.jpg",
38 | },
39 | {
40 | name: "Katie Williams",
41 | photo: "https://randomuser.me/api/portraits/women/5.jpg",
42 | },
43 | {
44 | name: "Jack Jones",
45 | photo: "https://randomuser.me/api/portraits/men/5.jpg",
46 | },
47 | {
48 | name: "Amy Johnson",
49 | photo: "https://randomuser.me/api/portraits/women/6.jpg",
50 | },
51 | {
52 | name: "Thomas Williams",
53 | photo: "https://randomuser.me/api/portraits/men/6.jpg",
54 | },
55 | {
56 | name: "Abigail Hernandez",
57 | photo: "https://randomuser.me/api/portraits/women/7.jpg",
58 | },
59 | {
60 | name: "Matthew Taylor",
61 | photo: "https://randomuser.me/api/portraits/men/7.jpg",
62 | },
63 | {
64 | name: "Poppy Jackson",
65 | photo: "https://randomuser.me/api/portraits/women/8.jpg",
66 | },
67 | {
68 | name: "Mohammed Lopez",
69 | photo: "https://randomuser.me/api/portraits/men/8.jpg",
70 | },
71 | ];
72 |
--------------------------------------------------------------------------------
/src/screens/Profile.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createMaterialTopTabNavigator,
3 | MaterialTopTabBarProps,
4 | } from "@react-navigation/material-top-tabs";
5 | import React, { FC, memo, useCallback, useMemo, useRef, useState } from "react";
6 | import {
7 | FlatList,
8 | FlatListProps,
9 | StyleProp,
10 | StyleSheet,
11 | View,
12 | ViewProps,
13 | ViewStyle,
14 | Text,
15 | useWindowDimensions,
16 | } from "react-native";
17 | import Animated, {
18 | interpolate,
19 | useAnimatedScrollHandler,
20 | useAnimatedStyle,
21 | useDerivedValue,
22 | useSharedValue,
23 | } from "react-native-reanimated";
24 | import Header from "../components/Header";
25 | import TabBar from "../components/TabBar";
26 | import useScrollSync from "../hooks/useScrollSync";
27 | import ConnectionList from "../components/ConnectionList";
28 | import { Connection } from "../types/Connection";
29 | import { ScrollPair } from "../types/ScrollPair";
30 | import { useSafeAreaInsets } from "react-native-safe-area-context";
31 | import { FRIENDS, SUGGESTIONS } from "../mocks/connections";
32 | import { HeaderConfig } from "../types/HeaderConfig";
33 | import { Visibility } from "../types/Visibility";
34 | import HeaderOverlay from "../components/HeaderOverlay";
35 |
36 | const TAB_BAR_HEIGHT = 48;
37 | const HEADER_HEIGHT = 48;
38 |
39 | const OVERLAY_VISIBILITY_OFFSET = 32;
40 |
41 | const Tab = createMaterialTopTabNavigator();
42 |
43 | const Profile: FC = () => {
44 | const { top, bottom } = useSafeAreaInsets();
45 |
46 | const { height: screenHeight } = useWindowDimensions();
47 |
48 | const friendsRef = useRef(null);
49 | const suggestionsRef = useRef(null);
50 |
51 | const [tabIndex, setTabIndex] = useState(0);
52 |
53 | const [headerHeight, setHeaderHeight] = useState(0);
54 |
55 | const defaultHeaderHeight = top + HEADER_HEIGHT;
56 |
57 | const headerConfig = useMemo(
58 | () => ({
59 | heightCollapsed: defaultHeaderHeight,
60 | heightExpanded: headerHeight,
61 | }),
62 | [defaultHeaderHeight, headerHeight]
63 | );
64 |
65 | const { heightCollapsed, heightExpanded } = headerConfig;
66 |
67 | const headerDiff = heightExpanded - heightCollapsed;
68 |
69 | const rendered = headerHeight > 0;
70 |
71 | const handleHeaderLayout = useCallback>(
72 | (event) => setHeaderHeight(event.nativeEvent.layout.height),
73 | []
74 | );
75 |
76 | const friendsScrollValue = useSharedValue(0);
77 |
78 | const friendsScrollHandler = useAnimatedScrollHandler(
79 | (event) => (friendsScrollValue.value = event.contentOffset.y)
80 | );
81 |
82 | const suggestionsScrollValue = useSharedValue(0);
83 |
84 | const suggestionsScrollHandler = useAnimatedScrollHandler(
85 | (event) => (suggestionsScrollValue.value = event.contentOffset.y)
86 | );
87 |
88 | const scrollPairs = useMemo(
89 | () => [
90 | { list: friendsRef, position: friendsScrollValue },
91 | { list: suggestionsRef, position: suggestionsScrollValue },
92 | ],
93 | [friendsRef, friendsScrollValue, suggestionsRef, suggestionsScrollValue]
94 | );
95 |
96 | const { sync } = useScrollSync(scrollPairs, headerConfig);
97 |
98 | const сurrentScrollValue = useDerivedValue(
99 | () =>
100 | tabIndex === 0 ? friendsScrollValue.value : suggestionsScrollValue.value,
101 | [tabIndex, friendsScrollValue, suggestionsScrollValue]
102 | );
103 |
104 | const translateY = useDerivedValue(
105 | () => -Math.min(сurrentScrollValue.value, headerDiff)
106 | );
107 |
108 | const tabBarAnimatedStyle = useAnimatedStyle(() => ({
109 | transform: [{ translateY: translateY.value }],
110 | }));
111 |
112 | const headerAnimatedStyle = useAnimatedStyle(() => ({
113 | transform: [{ translateY: translateY.value }],
114 | opacity: interpolate(
115 | translateY.value,
116 | [-headerDiff, 0],
117 | [Visibility.Hidden, Visibility.Visible]
118 | ),
119 | }));
120 |
121 | const contentContainerStyle = useMemo>(
122 | () => ({
123 | paddingTop: rendered ? headerHeight + TAB_BAR_HEIGHT : 0,
124 | paddingBottom: bottom,
125 | minHeight: screenHeight + headerDiff,
126 | }),
127 | [rendered, headerHeight, bottom, screenHeight, headerDiff]
128 | );
129 |
130 | const sharedProps = useMemo>>(
131 | () => ({
132 | contentContainerStyle,
133 | onMomentumScrollEnd: sync,
134 | onScrollEndDrag: sync,
135 | scrollEventThrottle: 16,
136 | scrollIndicatorInsets: { top: heightExpanded },
137 | }),
138 | [contentContainerStyle, sync, heightExpanded]
139 | );
140 |
141 | const renderFriends = useCallback(
142 | () => (
143 |
149 | ),
150 | [friendsRef, friendsScrollHandler, sharedProps]
151 | );
152 |
153 | const renderSuggestions = useCallback(
154 | () => (
155 |
161 | ),
162 | [suggestionsRef, suggestionsScrollHandler, sharedProps]
163 | );
164 |
165 | const tabBarStyle = useMemo>(
166 | () => [
167 | rendered ? styles.tabBarContainer : undefined,
168 | { top: rendered ? headerHeight : undefined },
169 | tabBarAnimatedStyle,
170 | ],
171 | [rendered, headerHeight, tabBarAnimatedStyle]
172 | );
173 |
174 | const renderTabBar = useCallback<
175 | (props: MaterialTopTabBarProps) => React.ReactElement
176 | >(
177 | (props) => (
178 |
179 |
180 |
181 | ),
182 | [tabBarStyle]
183 | );
184 |
185 | const headerContainerStyle = useMemo>(
186 | () => [
187 | rendered ? styles.headerContainer : undefined,
188 | { paddingTop: top },
189 | headerAnimatedStyle,
190 | ],
191 |
192 | [rendered, top, headerAnimatedStyle]
193 | );
194 |
195 | const collapsedOverlayAnimatedStyle = useAnimatedStyle(() => ({
196 | opacity: interpolate(
197 | translateY.value,
198 | [-headerDiff, OVERLAY_VISIBILITY_OFFSET - headerDiff, 0],
199 | [Visibility.Visible, Visibility.Hidden, Visibility.Hidden]
200 | ),
201 | }));
202 |
203 | const collapsedOverlayStyle = useMemo>(
204 | () => [
205 | styles.collapsedOvarlay,
206 | collapsedOverlayAnimatedStyle,
207 | { height: heightCollapsed, paddingTop: top },
208 | ],
209 | [collapsedOverlayAnimatedStyle, heightCollapsed, top]
210 | );
211 |
212 | return (
213 |
214 |
215 |
220 |
221 |
222 |
223 |
224 |
225 | {renderFriends}
226 | {renderSuggestions}
227 |
228 |
229 | );
230 | };
231 |
232 | const styles = StyleSheet.create({
233 | container: {
234 | flex: 1,
235 | backgroundColor: "white",
236 | },
237 | tabBarContainer: {
238 | top: 0,
239 | left: 0,
240 | right: 0,
241 | position: "absolute",
242 | zIndex: 1,
243 | },
244 | overlayName: {
245 | fontSize: 24,
246 | },
247 | collapsedOvarlay: {
248 | position: "absolute",
249 | top: 0,
250 | left: 0,
251 | right: 0,
252 | backgroundColor: "white",
253 | justifyContent: "center",
254 | zIndex: 2,
255 | },
256 | headerContainer: {
257 | top: 0,
258 | left: 0,
259 | right: 0,
260 | position: "absolute",
261 | zIndex: 1,
262 | },
263 | });
264 |
265 | export default memo(Profile);
266 |
--------------------------------------------------------------------------------
/src/types/Connection.ts:
--------------------------------------------------------------------------------
1 | export type Connection = {
2 | photo: string;
3 | name: string;
4 | };
5 |
--------------------------------------------------------------------------------
/src/types/HeaderConfig.ts:
--------------------------------------------------------------------------------
1 | export type HeaderConfig = {
2 | heightExpanded: number;
3 | heightCollapsed: number;
4 | };
5 |
--------------------------------------------------------------------------------
/src/types/ScrollPair.ts:
--------------------------------------------------------------------------------
1 | import { RefObject } from "react";
2 | import { FlatList } from "react-native";
3 | import Animated from "react-native-reanimated";
4 |
5 | export type ScrollPair = {
6 | list: RefObject;
7 | position: Animated.SharedValue;
8 | };
9 |
--------------------------------------------------------------------------------
/src/types/Visibility.ts:
--------------------------------------------------------------------------------
1 | export enum Visibility {
2 | Hidden = 0,
3 | Visible = 1,
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react-native",
5 | "lib": ["dom", "esnext"],
6 | "moduleResolution": "node",
7 | "noEmit": true,
8 | "skipLibCheck": true,
9 | "resolveJsonModule": true,
10 | "strict": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------