react-native-tinyui 0.5.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +468 -28
  2. package/ios/ConcentricView/TinyuiConcentricView.h +12 -0
  3. package/ios/ConcentricView/TinyuiConcentricView.mm +60 -0
  4. package/ios/Core/Tinyui.mm +54 -0
  5. package/ios/Core/TinyuiComponentClasses.mm +44 -0
  6. package/ios/Menu/TinyuiMenuBuilder.swift +238 -0
  7. package/ios/Menu/TinyuiMenuProvider.swift +141 -75
  8. package/ios/Menu/TinyuiMenuView.mm +24 -35
  9. package/ios/Popover/TinyuiPopoverProvider.swift +198 -44
  10. package/ios/Popover/TinyuiPopoverView.mm +26 -1
  11. package/ios/SFSymbol/TinyuiSFSymbolEffects.h +9 -0
  12. package/ios/SFSymbol/TinyuiSFSymbolEffects.mm +180 -0
  13. package/ios/SFSymbol/TinyuiSFSymbolImage.h +8 -0
  14. package/ios/SFSymbol/TinyuiSFSymbolImage.mm +141 -0
  15. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.h +31 -0
  16. package/ios/SFSymbol/TinyuiSFSymbolShadowNode.mm +30 -0
  17. package/ios/SFSymbol/TinyuiSFSymbolView.h +4 -0
  18. package/ios/SFSymbol/TinyuiSFSymbolView.mm +173 -0
  19. package/ios/Stepper/TinyuiStepperShadowNode.h +32 -0
  20. package/ios/Stepper/TinyuiStepperShadowNode.mm +28 -0
  21. package/ios/Stepper/TinyuiStepperView.h +12 -0
  22. package/ios/Stepper/TinyuiStepperView.mm +128 -0
  23. package/ios/TipKit/TinyuiTip.swift +31 -0
  24. package/ios/TipKit/TinyuiTipPopoverProvider.swift +276 -0
  25. package/ios/TipKit/TinyuiTipPopoverView.h +8 -0
  26. package/ios/TipKit/TinyuiTipPopoverView.mm +114 -0
  27. package/ios/TipKit/TinyuiTipsRuntime.swift +148 -0
  28. package/lib/module/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
  29. package/lib/module/ConcentricView/index.js +29 -0
  30. package/lib/module/ConcentricView/index.js.map +1 -0
  31. package/lib/module/ConcentricView/types.js +4 -0
  32. package/lib/module/ConcentricView/types.js.map +1 -0
  33. package/lib/module/Menu/MenuNativeComponent.ts +12 -3
  34. package/lib/module/Menu/index.js +17 -12
  35. package/lib/module/Menu/index.js.map +1 -1
  36. package/lib/module/Menu/options.js +4 -12
  37. package/lib/module/Menu/options.js.map +1 -1
  38. package/lib/module/NativeTinyui.js.map +1 -1
  39. package/lib/module/Popover/index.js +4 -2
  40. package/lib/module/Popover/index.js.map +1 -1
  41. package/lib/module/SFSymbol/SFSymbolNativeComponent.ts +20 -0
  42. package/lib/module/SFSymbol/index.js +73 -0
  43. package/lib/module/SFSymbol/index.js.map +1 -0
  44. package/lib/module/SFSymbol/options.js +79 -0
  45. package/lib/module/SFSymbol/options.js.map +1 -0
  46. package/lib/module/SFSymbol/types.js +4 -0
  47. package/lib/module/SFSymbol/types.js.map +1 -0
  48. package/lib/module/Stepper/StepperNativeComponent.ts +29 -0
  49. package/lib/module/Stepper/index.js +75 -0
  50. package/lib/module/Stepper/index.js.map +1 -0
  51. package/lib/module/Stepper/types.js +4 -0
  52. package/lib/module/Stepper/types.js.map +1 -0
  53. package/lib/module/TipKit/Popover.js +89 -0
  54. package/lib/module/TipKit/Popover.js.map +1 -0
  55. package/lib/module/TipKit/TipPopoverNativeComponent.ts +37 -0
  56. package/lib/module/TipKit/index.js +28 -0
  57. package/lib/module/TipKit/index.js.map +1 -0
  58. package/lib/module/TipKit/types.js +4 -0
  59. package/lib/module/TipKit/types.js.map +1 -0
  60. package/lib/module/TipKit/validation.js +24 -0
  61. package/lib/module/TipKit/validation.js.map +1 -0
  62. package/lib/module/index.js +4 -0
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/utils/getNativeTinyui.js.map +1 -1
  65. package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts +7 -0
  66. package/lib/typescript/src/ConcentricView/ConcentricViewNativeComponent.d.ts.map +1 -0
  67. package/lib/typescript/src/ConcentricView/index.d.ts +4 -0
  68. package/lib/typescript/src/ConcentricView/index.d.ts.map +1 -0
  69. package/lib/typescript/src/ConcentricView/types.d.ts +6 -0
  70. package/lib/typescript/src/ConcentricView/types.d.ts.map +1 -0
  71. package/lib/typescript/src/Menu/MenuNativeComponent.d.ts +8 -4
  72. package/lib/typescript/src/Menu/MenuNativeComponent.d.ts.map +1 -1
  73. package/lib/typescript/src/Menu/index.d.ts +1 -1
  74. package/lib/typescript/src/Menu/index.d.ts.map +1 -1
  75. package/lib/typescript/src/Menu/options.d.ts +1 -5
  76. package/lib/typescript/src/Menu/options.d.ts.map +1 -1
  77. package/lib/typescript/src/Menu/types.d.ts +26 -7
  78. package/lib/typescript/src/Menu/types.d.ts.map +1 -1
  79. package/lib/typescript/src/NativeTinyui.d.ts +2 -0
  80. package/lib/typescript/src/NativeTinyui.d.ts.map +1 -1
  81. package/lib/typescript/src/Popover/index.d.ts.map +1 -1
  82. package/lib/typescript/src/Popover/types.d.ts +2 -0
  83. package/lib/typescript/src/Popover/types.d.ts.map +1 -1
  84. package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts +11 -0
  85. package/lib/typescript/src/SFSymbol/SFSymbolNativeComponent.d.ts.map +1 -0
  86. package/lib/typescript/src/SFSymbol/index.d.ts +4 -0
  87. package/lib/typescript/src/SFSymbol/index.d.ts.map +1 -0
  88. package/lib/typescript/src/SFSymbol/options.d.ts +86 -0
  89. package/lib/typescript/src/SFSymbol/options.d.ts.map +1 -0
  90. package/lib/typescript/src/SFSymbol/types.d.ts +113 -0
  91. package/lib/typescript/src/SFSymbol/types.d.ts.map +1 -0
  92. package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts +21 -0
  93. package/lib/typescript/src/Stepper/StepperNativeComponent.d.ts.map +1 -0
  94. package/lib/typescript/src/Stepper/index.d.ts +4 -0
  95. package/lib/typescript/src/Stepper/index.d.ts.map +1 -0
  96. package/lib/typescript/src/Stepper/types.d.ts +24 -0
  97. package/lib/typescript/src/Stepper/types.d.ts.map +1 -0
  98. package/lib/typescript/src/TipKit/Popover.d.ts +3 -0
  99. package/lib/typescript/src/TipKit/Popover.d.ts.map +1 -0
  100. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts +38 -0
  101. package/lib/typescript/src/TipKit/TipPopoverNativeComponent.d.ts.map +1 -0
  102. package/lib/typescript/src/TipKit/index.d.ts +10 -0
  103. package/lib/typescript/src/TipKit/index.d.ts.map +1 -0
  104. package/lib/typescript/src/TipKit/types.d.ts +46 -0
  105. package/lib/typescript/src/TipKit/types.d.ts.map +1 -0
  106. package/lib/typescript/src/TipKit/validation.d.ts +4 -0
  107. package/lib/typescript/src/TipKit/validation.d.ts.map +1 -0
  108. package/lib/typescript/src/index.d.ts +9 -1
  109. package/lib/typescript/src/index.d.ts.map +1 -1
  110. package/lib/typescript/src/utils/getNativeTinyui.d.ts +1 -1
  111. package/lib/typescript/src/utils/getNativeTinyui.d.ts.map +1 -1
  112. package/package.json +37 -2
  113. package/{Tinyui.podspec → react-native-tinyui.podspec} +23 -2
  114. package/src/ConcentricView/ConcentricViewNativeComponent.ts +14 -0
  115. package/src/ConcentricView/index.tsx +34 -0
  116. package/src/ConcentricView/types.ts +6 -0
  117. package/src/Menu/MenuNativeComponent.ts +12 -3
  118. package/src/Menu/index.tsx +35 -12
  119. package/src/Menu/options.ts +2 -20
  120. package/src/Menu/types.ts +28 -7
  121. package/src/NativeTinyui.ts +2 -0
  122. package/src/Popover/index.tsx +8 -3
  123. package/src/Popover/types.ts +2 -0
  124. package/src/SFSymbol/SFSymbolNativeComponent.ts +20 -0
  125. package/src/SFSymbol/index.tsx +89 -0
  126. package/src/SFSymbol/options.ts +116 -0
  127. package/src/SFSymbol/types.ts +158 -0
  128. package/src/Stepper/StepperNativeComponent.ts +29 -0
  129. package/src/Stepper/index.tsx +91 -0
  130. package/src/Stepper/types.ts +24 -0
  131. package/src/TipKit/Popover.tsx +87 -0
  132. package/src/TipKit/TipPopoverNativeComponent.ts +37 -0
  133. package/src/TipKit/index.ts +47 -0
  134. package/src/TipKit/types.ts +57 -0
  135. package/src/TipKit/validation.ts +39 -0
  136. package/src/index.tsx +31 -0
  137. package/src/utils/getNativeTinyui.ts +8 -1
  138. package/ios/Core/TinyuiHostingHelpers.swift +0 -74
  139. package/ios/Menu/TinyuiMenuViewSwift.swift +0 -326
  140. package/ios/Popover/TinyuiPopoverViewSwift.swift +0 -88
package/README.md CHANGED
@@ -1,15 +1,47 @@
1
- # react-native-tinyui
1
+ <img src="./assets/banner.png" alt="TinyUI — Native iOS components for React Native" width="100%" />
2
2
 
3
- Dependency-free, native iOS `Menu`, `Popover` and `LiquidGlassText` components for React Native's
4
- New Architecture. The public API follows normal React composition patterns;
5
- the native UI is implemented with SwiftUI behind Fabric components.
3
+ # react-native-tinyui
6
4
 
7
- The initial component behavior is inspired by
8
- [`@expo/ui`](https://github.com/expo/expo/tree/main/packages/expo-ui), but this
9
- package does not require Expo Modules or any other runtime package. The
10
- React Native ↔ SwiftUI bridge follows the approach used by
11
- [`react-native-pager-view`](https://github.com/callstack/react-native-pager-view):
12
- a Fabric component view hosts the SwiftUI view through a `UIHostingController`.
5
+ A dependency-free collection of native iOS components for React Native's New
6
+ Architecture. Built on Fabric, TinyUI offers familiar React composition while
7
+ staying close to native APIs. It uses UIKit by default and SwiftUI where needed.
8
+
9
+ ## Components
10
+
11
+ | Component | What it does |
12
+ | ---------------------------------- | -------------------------------------------------------------- |
13
+ | [Menu](#menu) | Present native menus with actions, submenus, and sections |
14
+ | [Popover](#popover) | Show interactive React Native content in a native popover |
15
+ | [TipKit](#tipkit) | Present contextual tips using Apple's TipKit framework |
16
+ | [LiquidGlassText](#liquidglasstext) | Render text with native glass effects inside the glyphs |
17
+ | [Stepper](#stepper) | Adjust numeric values with the native iOS stepper |
18
+ | [ConcentricView](#concentricview) | Follow the containing view's corners with concentric rounding |
19
+ | [SFSymbol](#sfsymbol) | Display SF Symbols with native rendering and animation effects |
20
+
21
+ Screenshots below show the example app on an iPhone 17 Pro simulator running
22
+ iOS 26.5.
23
+
24
+ ## Philosophy and comparison with `@expo/ui`
25
+
26
+ TinyUI prioritizes performance, stability, a small app footprint, and familiar
27
+ React composition. These priorities guide which components we add and how we
28
+ bridge them.
29
+
30
+ | Design choice | `react-native-tinyui` | `@expo/ui` |
31
+ | --- | --- | --- |
32
+ | Runtime dependencies | Zero additional runtime dependencies beyond `react` and `react-native`. No `expo`, Expo Modules, or `react-native-nitro-modules` required. | Uses Expo Modules; existing React Native apps must [install `expo`](https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/#installation). |
33
+ | Native implementation | Performance and stability first. Prefers direct UIKit integration through Fabric; uses SwiftUI when a component needs it. | Uses [SwiftUI on iOS](https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/), with its layout and hosting model. |
34
+ | Selective inclusion and app size | Component-specific JS imports avoid loading unrelated component modules. An explicit native component list excludes unselected UI implementations and their framework requirements from the pod. | Provides platform-specific JS entry points. Its [iOS podspec](https://github.com/expo/expo/blob/main/packages/expo-ui/ios/ExpoUI.podspec) includes native sources together, without a per-component selection list. JS imports alone do not select native sources. |
35
+ | Component scope | Welcomes both system UI and third-party UI, such as [GlassText](#liquidglasstext). Adding components to the catalog does not require apps to include their UI implementations when they remain unselected. | Centers on SwiftUI and Jetpack Compose. Supports [custom SwiftUI components and modifiers, including third-party SwiftUI libraries](https://docs.expo.dev/guides/expo-ui-swift-ui/extending/). |
36
+ | React API | Familiar props, children, and controlled/uncontrolled state, composed with ordinary React Native views. Names and behavior stay close to the underlying native APIs. | Exposes native toolkit concepts through React, including SwiftUI views, modifiers, and a [`Host` container](https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/#usage). |
37
+ | Supported targets and maintenance | iOS 17+ and the New Architecture only. Excluding older iOS versions and the legacy bridge reduces compatibility code and keeps maintenance focused. | Covers a broader platform scope: SwiftUI on iOS, Jetpack Compose on Android, and [universal components for iOS, Android, and web](https://docs.expo.dev/versions/latest/sdk/ui/universal/). Requirements depend on the Expo SDK and component. |
38
+
39
+ For the smallest builds, combine component-specific JS imports with
40
+ [`react-native-tinyui.components`](#optional-component-selection). All native
41
+ components are enabled by default, so configure that list explicitly. Shared
42
+ core code, Fabric Codegen, and lightweight registration stubs remain in the
43
+ native build; the size savings come from excluding unused UI implementations.
44
+ Final bundle size also depends on the app's bundler and linker settings.
13
45
 
14
46
  ## Requirements
15
47
 
@@ -45,6 +77,10 @@ Available components:
45
77
  - `Menu`
46
78
  - `Popover`
47
79
  - `LiquidGlassText`
80
+ - `Stepper`
81
+ - `ConcentricView`
82
+ - `SFSymbol`
83
+ - `TipKit` (enables `TipKit.Popover`, `TipKit.configure` and `TipKit.invalidate`)
48
84
 
49
85
  All components are enabled by default. Use an empty `components` array to
50
86
  compile only the TurboModule core. Run `pod install` again whenever this list
@@ -57,6 +93,10 @@ Component-specific JavaScript entry points are also available:
57
93
  import { Menu } from 'react-native-tinyui/menu';
58
94
  import { Popover } from 'react-native-tinyui/popover';
59
95
  import { LiquidGlassText } from 'react-native-tinyui/liquid-glass-text';
96
+ import { Stepper } from 'react-native-tinyui/stepper';
97
+ import { ConcentricView } from 'react-native-tinyui/concentric-view';
98
+ import { SFSymbol } from 'react-native-tinyui/sf-symbol';
99
+ import { TipKit } from 'react-native-tinyui/tip-kit';
60
100
  ```
61
101
 
62
102
  The root `react-native-tinyui` imports remain supported for backwards
@@ -65,10 +105,11 @@ compatibility. JavaScript entry points provide cleaner dependency boundaries;
65
105
 
66
106
  ## Menu
67
107
 
108
+ <img src="docs/screenshots/menu.png" alt="Menu with native document actions expanded" width="320" />
109
+
68
110
  `Menu` uses `children` as its trigger and receives its native menu entries
69
- through the `options` prop. Without `onPrimaryAction`, a tap opens the menu.
70
- When `onPrimaryAction` is present, a tap invokes it and a long press opens the
71
- menu.
111
+ through the `options` prop. A tap opens the native menu; `onActionPress`
112
+ receives every selected action's stable id and displayed title.
72
113
 
73
114
  ```tsx
74
115
  import { Menu } from 'react-native-tinyui';
@@ -76,34 +117,40 @@ import { Menu } from 'react-native-tinyui';
76
117
  <Menu
77
118
  accessibilityLabel="Document actions"
78
119
  title="Document actions"
79
- onPrimaryAction={() => openDocument()}
120
+ onActionPress={({ nativeEvent }) => {
121
+ if (nativeEvent.id === 'rename') rename();
122
+ if (nativeEvent.id === 'toggle-pinned') togglePinned();
123
+ if (nativeEvent.id === 'copy-link') copyLink();
124
+ if (nativeEvent.id === 'invite-people') invitePeople();
125
+ if (nativeEvent.id === 'remove') remove();
126
+ }}
80
127
  options={[
81
128
  {
129
+ id: 'rename',
82
130
  title: 'Rename',
83
131
  systemImage: 'square.and.pencil',
84
- onSelect: rename,
85
132
  },
86
133
  {
134
+ id: 'toggle-pinned',
87
135
  title: 'Pinned',
88
136
  state: 'on',
89
137
  systemImage: 'pin',
90
- onSelect: togglePinned,
91
138
  },
92
139
  {
93
140
  type: 'submenu',
94
141
  title: 'Share',
95
142
  systemImage: 'square.and.arrow.up',
96
143
  options: [
97
- { title: 'Copy link', onSelect: copyLink },
98
- { title: 'Invite people', onSelect: invitePeople },
144
+ { id: 'copy-link', title: 'Copy link' },
145
+ { id: 'invite-people', title: 'Invite people' },
99
146
  ],
100
147
  },
101
148
  { type: 'divider' },
102
149
  {
150
+ id: 'remove',
103
151
  title: 'Delete',
104
152
  destructive: true,
105
153
  systemImage: 'trash',
106
- onSelect: remove,
107
154
  },
108
155
  ]}
109
156
  >
@@ -118,15 +165,46 @@ or `type: 'divider'` for structural entries; nested entries use their own
118
165
  `options` array. SF Symbols are passed with `systemImage`; missing symbols
119
166
  simply render no image.
120
167
 
168
+ To open the menu from another control, call `open()` on its ref:
169
+
170
+ ```tsx
171
+ import { useRef } from 'react';
172
+ import { Button, Text } from 'react-native';
173
+ import { Menu, type MenuRef } from 'react-native-tinyui/menu';
174
+
175
+ function DocumentMenu() {
176
+ const menuRef = useRef<MenuRef>(null);
177
+
178
+ return (
179
+ <>
180
+ <Menu ref={menuRef} options={[{ id: 'rename', title: 'Rename' }]}>
181
+ <Text>Document actions</Text>
182
+ </Menu>
183
+ <Button title="Open menu" onPress={() => menuRef.current?.open()} />
184
+ </>
185
+ );
186
+ }
187
+ ```
188
+
189
+ `open()` requires iOS 17.4 or newer and anchors the menu to its mounted trigger.
190
+ It does nothing on older iOS versions, while `disabled`, or when the trigger is
191
+ detached. Tapping the trigger still works on all supported iOS versions. The ref
192
+ also retains the outer View's methods, such as `measure` and `measureInWindow`.
193
+
121
194
  Action options support `subtitle`, `state`, `destructive`, `disabled`, `hidden`,
122
- and `keepOpen`. Use `titleColor` to customize the title, or `icon` to load an
123
- image from the containing app's asset catalog; `iconColor` tints either an asset
124
- image or an SF Symbol. These colors accept React Native `ColorValue`s, including
125
- dynamic and semantic iOS colors. Submenus support the same label fields plus
126
- `destructive`, `disabled`, `hidden`, and `displayInline`.
195
+ and `keepOpen`. Set explicit `id` values when handling `onActionPress`; actions
196
+ without one receive a generated menu-path id. Use `icon` to load an image from
197
+ the containing app's asset catalog; `iconColor` tints either an asset image or
198
+ an SF Symbol and accepts React Native `ColorValue`s, including dynamic and
199
+ semantic iOS colors. UIKit doesn't expose custom menu title colors, so the
200
+ deprecated `titleColor` field is retained only for source compatibility; use
201
+ `destructive` for system red styling. Submenus support the same label fields
202
+ plus `destructive`, `disabled`, `hidden`, and `displayInline`.
127
203
 
128
204
  ## Popover
129
205
 
206
+ <img src="docs/screenshots/popover.png" alt="Popover displaying resizable content above its trigger" width="320" />
207
+
130
208
  `Popover` supports standard controlled and uncontrolled React state. Its content
131
209
  remains a normal interactive React Native view tree hosted by a real
132
210
  `UIPopoverPresentationController`.
@@ -138,10 +216,9 @@ import { Popover, PopoverClose } from 'react-native-tinyui';
138
216
  defaultOpen={false}
139
217
  attachmentAnchor="bottom"
140
218
  arrowEdge="top"
141
- contentSize={{ width: 320, height: 220 }}
142
219
  onOpenChange={(open) => console.log({ open })}
143
220
  content={({ close }) => (
144
- <View style={styles.content}>
221
+ <View style={{ width: 280, padding: 20 }}>
145
222
  <Text>Any React Native content can be rendered here.</Text>
146
223
  <PopoverClose close={close} style={styles.doneButton}>
147
224
  <Text>Done</Text>
@@ -161,11 +238,121 @@ the `content` prop, which can be a node or a render function receiving
161
238
  as `Popover.Close`) renders a `Pressable` that calls `close` when pressed.
162
239
 
163
240
  Use `open` with `onOpenChange` for a controlled popover, or `defaultOpen` for an
164
- uncontrolled one. `contentSize` defaults to 320 × 240 points and can be updated
165
- while the popover is visible.
241
+ uncontrolled one. The layout of `content` determines the popover's preferred
242
+ size: set `width` and optionally `height` on its root view, or let its children
243
+ determine the height. Size changes are applied while the popover is visible.
244
+ UIKit may limit the displayed size to the available space on screen.
245
+
246
+ Keep the content background transparent to show the native popover material:
247
+ Liquid Glass on iOS 26+ when built with Xcode 26+, and the system popover
248
+ appearance on earlier iOS versions. `style` on `Popover` styles the trigger's
249
+ outer container, not the presented content.
250
+
251
+ ## TipKit
252
+
253
+ <img src="docs/screenshots/tip-kit.png" alt="TipKit popover with a save action anchored to the favorite button" width="320" />
254
+
255
+ `TipKit.Popover` anchors a native TipKit `TipUIPopoverViewController` to ordinary
256
+ React Native children on iOS 17+. The children retain their own touch handlers;
257
+ the tip appears automatically when both `enabled` and TipKit's eligibility allow
258
+ it. Its text, SF Symbol, action buttons, display history and invalidation use
259
+ Apple's TipKit framework. No additional package is required.
260
+
261
+ Configure TipKit once, **before mounting tips** (for example, during app startup):
262
+
263
+ ```tsx
264
+ import { TipKit, SFSymbol } from 'react-native-tinyui';
265
+ import { Pressable } from 'react-native';
266
+
267
+ await TipKit.configure({ displayFrequency: 'daily' });
268
+
269
+ // Inside your screen:
270
+ <TipKit.Popover
271
+ tipId="favorite-feature-v1"
272
+ title="Keep your favorites close"
273
+ message="Save an item to find it quickly next time."
274
+ systemImage="star"
275
+ enabled={isScreenFocused && !hasFavorites}
276
+ maxDisplayCount={3}
277
+ actions={[{ id: 'learn-more', title: 'Learn more' }]}
278
+ onActionPress={({ id }) => {
279
+ if (id === 'learn-more') openHelp();
280
+ }}
281
+ onStatusChange={(status) => console.log(status)}
282
+ onVisibleChange={(visible) => console.log({ visible })}
283
+ onError={(error) => console.warn(error.message)}
284
+ >
285
+ <Pressable onPress={async () => {
286
+ await saveFavorite();
287
+ await TipKit.invalidate('favorite-feature-v1', 'actionPerformed');
288
+ }}>
289
+ <SFSymbol name="star" size={24} />
290
+ </Pressable>
291
+ </TipKit.Popover>;
292
+ ```
293
+
294
+ | Prop | Description | Default |
295
+ | --- | --- | --- |
296
+ | `tipId` | Required stable identity for persistent history | — |
297
+ | `title` | Required nonempty title, already localized by the app | — |
298
+ | `message` | Supporting plain text | — |
299
+ | `systemImage` | SF Symbol name | — |
300
+ | `actions` | Buttons with unique nonempty `{ id, title }` values | `[]` |
301
+ | `enabled` | Allows presentation; does not override TipKit eligibility | `true` |
302
+ | `maxDisplayCount` | Positive integer; automatically invalidates after this many displays | Unlimited |
303
+ | `ignoresDisplayFrequency` | Exempts this tip from the app-wide display interval | `false` |
304
+ | `arrowEdge` | Preferred bubble edge: `top`, `bottom`, `leading`, `trailing`, `auto` | `auto` |
305
+ | `onStatusChange` | `{ status: 'pending' \| 'available' }` or `{ status: 'invalidated', reason }` | — |
306
+ | `onVisibleChange` | Whether the native bubble is actually visible | — |
307
+ | `onActionPress` | Receives the selected `{ id, title }`; does not automatically invalidate | — |
308
+ | `onError` | Receives `{ code, message }`; otherwise a warning is logged | — |
309
+
310
+ The component accepts standard `ViewProps` and a native view ref. `style` lays
311
+ out the anchor, not the bubble. UIKit chooses the bubble's size and may adapt
312
+ the arrow to available space; leading/trailing follow layout direction. Bubble
313
+ content is native text/images/actions; `children` supplies only the anchor.
314
+ Content edits apply to the next presentation; the currently visible bubble
315
+ retains the content and action labels it was presented with.
316
+
317
+ ### Configuration and lifetime
318
+
319
+ `TipKit.configure({ displayFrequency })` returns a promise. Supported frequencies
320
+ are `immediate`, `hourly`, `daily` (default), `weekly` and `monthly`. This is an
321
+ app-wide TipKit setting. Repeating the same configuration is safe, including
322
+ after Fast Refresh; changing it later rejects with `E_TIPS_ALREADY_CONFIGURED`.
323
+ Coordinate this startup configuration with any other native TipKit integration.
324
+ Initialization errors reject the promise; an enabled view mounted too early
325
+ reports `E_TIPS_NOT_CONFIGURED` and can recover after configuration succeeds.
326
+
327
+ `TipKit.invalidate(tipId, reason = 'actionPerformed')` also returns a promise and
328
+ requires configuration first. It persistently invalidates the ID even when no
329
+ view for that tip is mounted. `reason` may be `actionPerformed` or `tipClosed`.
330
+ System invalidations can additionally report `displayCountExceeded`,
331
+ `displayDurationExceeded`, or `unknown` through `onStatusChange`.
332
+
333
+ Keep IDs stable across renders, app launches and translations. All instances
334
+ with the same ID share their TipKit history. Keep `maxDisplayCount` and
335
+ `ignoresDisplayFrequency` consistent for each ID; conflicting values report
336
+ `E_CONFLICTING_TIP_OPTIONS`. Use a deliberate new versioned ID for a new feature
337
+ tip. Hiding a tip, unmounting it or reconfiguring TinyUI never resets history.
338
+
339
+ Use `enabled={isScreenFocused && businessCondition}` with your navigation
340
+ library. TinyUI also waits for a visible anchor and a free presenter, and
341
+ dismisses on detachment or when the anchor leaves the visible area. It presents
342
+ at most one TinyUI tip at a time and waits while another modal occupies the
343
+ presenter. An outside dismissal suppresses immediate reopening for that mount;
344
+ toggle `enabled` off and on to allow another attempt, subject to TipKit's state.
345
+ The system close button can permanently invalidate a tip.
346
+
347
+ This first version exposes popover tips and JS eligibility conditions. Inline
348
+ `TipUIView`, native rule/event builders, TipGroup and datastore reset APIs are
349
+ not exposed. The example's **New demo tip** button deliberately creates another
350
+ ID for trying the behavior again without clearing the app's TipKit datastore.
166
351
 
167
352
  ## LiquidGlassText
168
353
 
354
+ <img src="docs/screenshots/liquid-glass-text.png" alt="LiquidGlassText rendering English, Chinese, and numbers over a photo" width="320" />
355
+
169
356
  `LiquidGlassText` renders native glass inside the glyph outlines, based on
170
357
  [GlassText](https://github.com/ailtonvivaz/GlassText). It exposes the effect and
171
358
  typography through React props.
@@ -230,6 +417,259 @@ their translated string directly.
230
417
  The adapted Core Text outline implementation retains the upstream MIT notice in
231
418
  [`ios/LiquidGlassText/GlassText-LICENSE`](ios/LiquidGlassText/GlassText-LICENSE).
232
419
 
420
+ ## Stepper
421
+
422
+ <img src="docs/screenshots/stepper.png" alt="Native Stepper with a controlled value and configurable range" width="320" />
423
+
424
+ `Stepper` bridges UIKit's
425
+ [`UIStepper`](https://developer.apple.com/documentation/uikit/uistepper) through
426
+ Fabric. It supports fractional steps, press-and-hold repeat, and wrapping at
427
+ the bounds on iOS 17+.
428
+
429
+ ```tsx
430
+ import { useState } from 'react';
431
+ import { Stepper } from 'react-native-tinyui';
432
+
433
+ function Quantity() {
434
+ const [quantity, setQuantity] = useState(1);
435
+ return (
436
+ <Stepper
437
+ accessibilityLabel="Quantity"
438
+ value={quantity}
439
+ minimumValue={1}
440
+ maximumValue={10}
441
+ onValueChange={setQuantity}
442
+ />
443
+ );
444
+ }
445
+
446
+ // Uncontrolled: UIKit owns the value after initialization.
447
+ <Stepper defaultValue={2.5} stepValue={0.5} maximumValue={5} wraps />;
448
+ ```
449
+
450
+ | Prop | Description | Default |
451
+ | --- | --- | --- |
452
+ | `value` | Controlled numeric value | — |
453
+ | `defaultValue` | Initial value when uncontrolled; later changes are ignored | `0` |
454
+ | `minimumValue` | Lower bound | `0` |
455
+ | `maximumValue` | Upper bound, at least `minimumValue` | `100` |
456
+ | `stepValue` | Positive increment/decrement amount | `1` |
457
+ | `isContinuous` | Report changes during interaction; otherwise report on release | `true` |
458
+ | `autorepeat` | Repeatedly step while holding a button | `true` |
459
+ | `wraps` | Continue from the opposite bound when stepping past an end | `false` |
460
+ | `disabled` | Prevent user interaction | `false` |
461
+ | `onValueChange` | Callback receiving the new number after user interaction | — |
462
+
463
+ When `value` is supplied, update it in `onValueChange` to accept changes. Keeping
464
+ the same `value` restores the native control to that value. Omit `value` to let
465
+ UIKit manage the state. Programmatic value or range changes do not call
466
+ `onValueChange`.
467
+
468
+ All numeric props must be finite, `stepValue` must be positive, and
469
+ `maximumValue` must be at least `minimumValue`; invalid inputs throw a descriptive
470
+ error. Values are clamped to the current range. Equal bounds produce a fixed
471
+ value. Changing an uncontrolled range also clamps its current native value.
472
+
473
+ The component accepts standard `ViewProps` (except `children`) and a native view
474
+ ref. Native accessibility behavior is preserved; use `accessibilityLabel` to
475
+ name the value being adjusted. Fabric measures the native control's intrinsic
476
+ size. A larger `style` frame centers the native control without stretching
477
+ its buttons. Put padding on a surrounding `View`. Like UIKit's control, the
478
+ stepper shows the minus and plus buttons; render a separate `Text` for the value.
479
+
480
+ ## ConcentricView
481
+
482
+ <img src="docs/screenshots/concentric-view.png" alt="Nested ConcentricView containers following the bottom screen corners" width="320" />
483
+
484
+ `ConcentricView` is a Fabric container that lets UIKit resolve its corner radii
485
+ relative to its containing view. On iOS 26+, it applies
486
+ [`UICornerRadius.containerConcentricRadius`](https://developer.apple.com/documentation/uikit/uicornerradius-c.class/containerconcentricradius)
487
+ through `UIView.cornerConfiguration`. UIKit handles geometry and layout changes.
488
+ Building this component requires **Xcode 26+**; on iOS 17–25 it uses
489
+ `minimumRadius` as a fixed corner radius.
490
+
491
+ ```tsx
492
+ import { Text } from 'react-native';
493
+ import { ConcentricView } from 'react-native-tinyui';
494
+
495
+ <ConcentricView
496
+ minimumRadius={32}
497
+ style={{ padding: 12, backgroundColor: '#DCEBFF' }}
498
+ >
499
+ <ConcentricView
500
+ minimumRadius={12}
501
+ style={{ padding: 20, backgroundColor: '#3875D5' }}
502
+ >
503
+ <Text>UIKit resolves each corner.</Text>
504
+ </ConcentricView>
505
+ </ConcentricView>;
506
+ ```
507
+
508
+ `minimumRadius` defaults to `0` and must be finite and nonnegative. It only
509
+ controls the fixed fallback radius before iOS 26; iOS 26+ resolves corners
510
+ entirely through UIKit, without a minimum radius. The component accepts
511
+ `children`, standard `ViewProps`, and a native view ref. It clips content
512
+ by default; use `style={{ overflow: 'visible' }}` to allow overflow. Avoid
513
+ `style.borderRadius` and individual corner radii on this component.
514
+ React Native's custom border, outline, and shadow drawing does not calculate
515
+ concentric radii; keep those decorations on a surrounding view when needed.
516
+
517
+ ## SFSymbol
518
+
519
+ <img src="docs/screenshots/sf-symbol.png" alt="SFSymbol rendering a bell badge with native symbol colors" width="320" />
520
+
521
+ `SFSymbol` uses **UIKit `UIImageView` + Apple's Symbols framework**, exposed
522
+ through Fabric. It does not embed a SwiftUI hosting view. Xcode 26+ is required
523
+ to build; the deployment target remains iOS 17. Yoga measures the configured
524
+ symbol's natural size, so `style.width` and `style.height` are optional.
525
+
526
+ ```tsx
527
+ import { useState } from 'react';
528
+ import { Button, PlatformColor, View } from 'react-native';
529
+ import { SFSymbol } from 'react-native-tinyui';
530
+
531
+ function Favorite() {
532
+ const [selected, setSelected] = useState(false);
533
+ const [trigger, setTrigger] = useState(0);
534
+ return (
535
+ <View>
536
+ <SFSymbol
537
+ name={selected ? 'heart.fill' : 'heart'}
538
+ size={32}
539
+ weight="semibold"
540
+ renderingMode="hierarchical"
541
+ color={PlatformColor('systemPinkColor')}
542
+ effect={{ type: 'bounce', trigger, options: { repeat: false } }}
543
+ contentTransition={{ type: 'magicReplace', direction: 'downUp' }}
544
+ />
545
+ <Button title="Favorite" onPress={() => {
546
+ setSelected(!selected);
547
+ setTrigger(trigger + 1);
548
+ }} />
549
+ </View>
550
+ );
551
+ }
552
+ ```
553
+
554
+ ### Image configuration
555
+
556
+ | Prop | Values / behavior |
557
+ | --- | --- |
558
+ | `name` | Required complete symbol name, including variants such as `square.fill`. Missing symbols render empty. |
559
+ | `source` | `system` (default), or `asset` for a custom SF Symbol in the app's asset catalog. Ordinary bitmap assets are not accepted. |
560
+ | `size` | Positive point size; default `17`. |
561
+ | `weight` | `unspecified` (default), `ultraLight`, `thin`, `light`, `regular`, `medium`, `semibold`, `bold`, `heavy`, `black`. |
562
+ | `scale` | `default`, `unspecified`, `small`, `medium`, `large`. |
563
+ | `textStyle` | `extraLargeTitle`, `extraLargeTitle2`, `largeTitle`, `title1`–`title3`, `headline`, `subheadline`, `body`, `callout`, `footnote`, `caption1`, `caption2`. Overrides `size` and supplies the style's weight unless explicitly set. |
564
+ | `fontFamily` | Font/PostScript name used to derive symbol metrics; unknown fonts use system metrics. |
565
+ | `allowFontScaling` | Default `true`. Scales the base point size by React Native's `fontScale`, including when using `textStyle`. |
566
+ | `maxFontSizeMultiplier` | Cap on that scale, >= 1. Omitted or `0` means unlimited. |
567
+ | `renderingMode` | `automatic` (default), `monochrome`, `hierarchical`, `palette`, `multicolor`. |
568
+ | `color` | React Native `ColorValue`, including `PlatformColor` and `DynamicColorIOS`. Defaults to semantic label color. |
569
+ | `paletteColors` | One to three `ColorValue`s in primary, secondary, tertiary order, used in palette mode. If omitted, the palette uses `color`. |
570
+ | `variableValue` | Progress from `0` to `1`; omitted uses the symbol default. Requires a symbol with variable annotations. |
571
+ | `variableValueMode` | `automatic` (default), `color`, `draw` (iOS 26+). |
572
+ | `colorRenderingMode` | `automatic` (default), `flat`, `gradient` (iOS 26+). |
573
+ | `resizeMode` | `center` (default, natural point size), `contain`, `cover`, `stretch`, for explicitly sized frames. Use `style.overflow: 'hidden'` to clip. |
574
+
575
+ Standard `ViewProps`, styles and refs are supported. Symbols are decorative
576
+ (`accessible={false}`) by default; supply `accessible` and a meaningful
577
+ `accessibilityLabel` for standalone images. Use a `Pressable` for interactive icons.
578
+
579
+ ### Preset effects
580
+
581
+ Pass one object to `effect`, or an array of **distinct** effect types to combine
582
+ compatible effects. UIKit determines how combined effects interact.
583
+
584
+ | `type` | Configuration | Minimum iOS |
585
+ | --- | --- | --- |
586
+ | `bounce`, `scale`, `appear`, `disappear` | `direction: 'up' \| 'down'`, `scope` | 17 |
587
+ | `pulse` | `scope` | 17 |
588
+ | `variableColor` | `iteration: 'iterative' \| 'cumulative'`, `reversing: boolean`, `inactiveLayers: 'hide' \| 'dim'` | 17 |
589
+ | `wiggle` | `direction: 'up' \| 'down' \| 'left' \| 'right' \| 'forward' \| 'backward' \| 'clockwise' \| 'counterClockwise'`, or `angle` in degrees clockwise from +x; `scope` | 18 |
590
+ | `rotate` | `direction: 'clockwise' \| 'counterClockwise'`, `scope` | 18 |
591
+ | `breathe` | `style: 'plain' \| 'pulse'`, `scope` | 18 |
592
+ | `drawOn` | `scope`, also accepting `individually` | 26 |
593
+ | `drawOff` | `scope`, also accepting `individually`; `reversed: boolean` | 26 |
594
+
595
+ `scope` is `byLayer` or `wholeSymbol`; omit any modifier to keep Apple's default.
596
+ Each preset also accepts:
597
+
598
+ - `active` (default `true`): apply/remove the effect. Scale and visibility
599
+ effects hold their state until removed. Disappear/Draw Off can hide the symbol.
600
+ - `trigger`: a number or string. Change it to replay an effect. Mounting or
601
+ changing effect configuration also applies it; unrelated rerenders do not.
602
+ - `animated` (default `true`): animate application/removal of the effect.
603
+ - `options.speed`: positive speed multiplier, default system speed.
604
+ - `options.repeat`: a positive integer play count, `'forever'`, or `false` for
605
+ one play. Omit to use the preset's native repetition behavior.
606
+ - `options.repeatBehavior`: `periodic` or `continuous` (iOS 18+).
607
+ Continuous repeats forever and cannot be combined with a count or delay.
608
+ - `options.repeatDelay`: nonnegative delay in seconds between periodic plays
609
+ (iOS 18+). Without a count, periodic/delayed repetition continues indefinitely.
610
+
611
+ Repetition options only affect presets that support repeating. For example,
612
+ `scale` is a held state, not a looping animation. Removing `effect` clears all
613
+ effects. Detached/recycled views stop animations; reattaching applies them again.
614
+
615
+ ### Replacement transitions and compatibility
616
+
617
+ `contentTransition` animates changes to the name or image configuration. It
618
+ accepts `type: 'automatic' | 'replace' | 'magicReplace'`, optional
619
+ `direction: 'downUp' | 'upUp' | 'offUp'`, `scope: 'byLayer' | 'wholeSymbol'`, and
620
+ a positive `speed`. Direction and scope configure Replace and the Magic Replace
621
+ fallback; Automatic uses the system transition. The initial image is not transitioned.
622
+
623
+ Unsupported presets are ignored on earlier iOS versions. Magic Replace falls
624
+ back to the configured Replace before iOS 18. Continuous repetition falls back
625
+ to periodic repetition on iOS 17; custom repeat delay is ignored. Before iOS 26,
626
+ variable mode and gradient configuration are ignored. Actual animation, palette,
627
+ multicolor and variable/draw support depend on each symbol's native annotations.
628
+
629
+ `respectReduceMotion` defaults to `true`: repeating/discrete effects and content
630
+ transitions are suppressed while Reduce Motion is enabled, while scale and
631
+ visibility state changes apply instantly. Set it to `false` to opt out.
632
+
633
+ ## Example App
634
+
635
+ The [example app](example/src/App.tsx) demonstrates Menu, Popover, TipKit,
636
+ LiquidGlassText, Stepper, ConcentricView, and SFSymbol. It includes all native
637
+ components, so building it requires Xcode 26+. Use iOS 26+ to try the glass effects
638
+ and the latest SF Symbol features.
639
+
640
+ From the repository root, install the dependencies:
641
+
642
+ ```sh
643
+ yarn
644
+ ```
645
+
646
+ Install the example's Pods:
647
+
648
+ ```sh
649
+ cd example/ios && pod install
650
+ ```
651
+
652
+ Then run Metro and the iOS app from the repository root in separate terminals:
653
+
654
+ ```sh
655
+ yarn example start
656
+ ```
657
+
658
+ ```sh
659
+ yarn example ios
660
+ ```
661
+
662
+ The example uses Metro port `8082`, which is already configured in these scripts.
663
+ See the [development workflow](CONTRIBUTING.md#development-workflow) for more
664
+ details.
665
+
666
+ ## Apps Using This Library
667
+
668
+ - [Night Vision - LiDAR Camera](https://apps.apple.com/app/id1668629667)
669
+ - [Laser Measure - LiDAR Powered](https://apps.apple.com/app/id6466744678)
670
+ - [PhoneAway - Digital Detox](https://apps.apple.com/app/id6744548607)
671
+ - [Fatigue Alert - Stay Awake](https://apps.apple.com/app/id6479893638)
672
+
233
673
  ## Contributing
234
674
 
235
675
  - [Development workflow](CONTRIBUTING.md#development-workflow)
@@ -0,0 +1,12 @@
1
+ #if TINYUI_FEATURE_CONCENTRIC_VIEW
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface TinyuiConcentricView : RCTViewComponentView
8
+ @end
9
+
10
+ NS_ASSUME_NONNULL_END
11
+
12
+ #endif
@@ -0,0 +1,60 @@
1
+ #if TINYUI_FEATURE_CONCENTRIC_VIEW
2
+
3
+ #import "TinyuiConcentricView.h"
4
+
5
+ #import <react/renderer/components/TinyuiSpec/ComponentDescriptors.h>
6
+ #import <react/renderer/components/TinyuiSpec/Props.h>
7
+ #import <react/renderer/components/TinyuiSpec/RCTComponentViewHelpers.h>
8
+
9
+ using namespace facebook::react;
10
+
11
+ @interface TinyuiConcentricView () <RCTTinyuiConcentricViewViewProtocol>
12
+ @end
13
+
14
+ @implementation TinyuiConcentricView
15
+
16
+ + (ComponentDescriptorProvider)componentDescriptorProvider
17
+ {
18
+ return concreteComponentDescriptorProvider<TinyuiConcentricViewComponentDescriptor>();
19
+ }
20
+
21
+ - (instancetype)initWithFrame:(CGRect)frame
22
+ {
23
+ if (self = [super initWithFrame:frame]) {
24
+ static const auto defaultProps = std::make_shared<const TinyuiConcentricViewProps>();
25
+ _props = defaultProps;
26
+ }
27
+ return self;
28
+ }
29
+
30
+ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
31
+ {
32
+ [super finalizeUpdates:updateMask];
33
+ [self applyCornerConfiguration];
34
+ }
35
+
36
+ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
37
+ {
38
+ [super traitCollectionDidChange:previousTraitCollection];
39
+ if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) {
40
+ [self applyCornerConfiguration];
41
+ }
42
+ }
43
+
44
+ - (void)applyCornerConfiguration
45
+ {
46
+ // Fabric updates layer.cornerRadius while applying view styles. Configure
47
+ // UIKit afterwards so it owns the resolved radii, including on layout changes.
48
+ if (@available(iOS 26.0, *)) {
49
+ self.cornerConfiguration = [UICornerConfiguration configurationWithRadius:
50
+ [UICornerRadius containerConcentricRadius]];
51
+ } else {
52
+ const auto &props = *std::static_pointer_cast<const TinyuiConcentricViewProps>(_props);
53
+ self.layer.cornerRadius = props.minimumRadius;
54
+ self.layer.cornerCurve = kCACornerCurveContinuous;
55
+ }
56
+ }
57
+
58
+ @end
59
+
60
+ #endif