@rn-tools/core 3.0.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @rn-tools/core
2
+
3
+ ## 3.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e62a766: Initial changeset publish
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @rn-tools/core
2
+
3
+ Core utilities for rn-tools, including safe-area insets and a minimal external store helper.
@@ -0,0 +1,47 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.rntoolscore'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ def useManagedAndroidSdkVersions = false
13
+ if (useManagedAndroidSdkVersions) {
14
+ useDefaultAndroidSdkVersions()
15
+ } else {
16
+ buildscript {
17
+ ext.safeExtGet = { prop, fallback ->
18
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
19
+ }
20
+ }
21
+ project.android {
22
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
23
+ defaultConfig {
24
+ minSdkVersion safeExtGet("minSdkVersion", 21)
25
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
26
+ }
27
+ }
28
+ }
29
+
30
+ android {
31
+ namespace "expo.modules.rntoolscore"
32
+ defaultConfig {
33
+ versionCode 1
34
+ versionName "0.1.0"
35
+ }
36
+ lintOptions {
37
+ abortOnError false
38
+ }
39
+
40
+ repositories {
41
+ mavenCentral()
42
+ }
43
+
44
+ dependencies {
45
+ implementation 'com.facebook.react:react-native:+'
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,118 @@
1
+ package expo.modules.rntoolscore
2
+
3
+ import android.view.View
4
+ import android.view.ViewGroup
5
+ import com.facebook.react.bridge.UiThreadUtil
6
+ import expo.modules.kotlin.modules.Module
7
+ import expo.modules.kotlin.modules.ModuleDefinition
8
+ import java.util.concurrent.CountDownLatch
9
+ import java.util.concurrent.TimeUnit
10
+
11
+ class RNToolsCoreModule : Module() {
12
+ private var lastInsets: EdgeInsets? = null
13
+ private var attachedView: View? = null
14
+ private var insetsListener: View.OnApplyWindowInsetsListener? = null
15
+
16
+ override fun definition() = ModuleDefinition {
17
+ Name("RNToolsCore")
18
+
19
+ Events("onSafeAreaInsetsChange")
20
+
21
+ Function("getSafeAreaInsets") {
22
+ val fallback = mapOf(
23
+ "top" to 0f,
24
+ "right" to 0f,
25
+ "bottom" to 0f,
26
+ "left" to 0f
27
+ )
28
+
29
+ val activity = appContext.currentActivity ?: return@Function fallback
30
+ val decorView = activity.window?.decorView as? ViewGroup ?: return@Function fallback
31
+
32
+ if (UiThreadUtil.isOnUiThread()) {
33
+ val insets = getSafeAreaInsets(decorView) ?: return@Function fallback
34
+ return@Function insetsToMap(insets)
35
+ }
36
+
37
+ val latch = CountDownLatch(1)
38
+ var result: Map<String, Float> = fallback
39
+
40
+ UiThreadUtil.runOnUiThread {
41
+ val insets = getSafeAreaInsets(decorView)
42
+ result = if (insets == null) fallback else insetsToMap(insets)
43
+ latch.countDown()
44
+ }
45
+
46
+ latch.await(200, TimeUnit.MILLISECONDS)
47
+ return@Function result
48
+ }
49
+
50
+ OnCreate {
51
+ attachInsetsListener()
52
+ }
53
+
54
+ OnDestroy {
55
+ detachInsetsListener()
56
+ }
57
+
58
+ OnActivityEntersForeground {
59
+ attachInsetsListener()
60
+ }
61
+ }
62
+
63
+ private fun attachInsetsListener() {
64
+ UiThreadUtil.runOnUiThread {
65
+ val activity = appContext.currentActivity ?: return@runOnUiThread
66
+ val decorView = activity.window?.decorView as? ViewGroup ?: return@runOnUiThread
67
+
68
+ attachedView = decorView
69
+ insetsListener = View.OnApplyWindowInsetsListener { view, insets ->
70
+ emitSafeAreaInsetsIfChanged(view)
71
+ view.onApplyWindowInsets(insets)
72
+ }
73
+
74
+ decorView.setOnApplyWindowInsetsListener(insetsListener)
75
+ decorView.requestApplyInsets()
76
+ emitSafeAreaInsetsIfChanged(decorView)
77
+ }
78
+ }
79
+
80
+ private fun detachInsetsListener() {
81
+ UiThreadUtil.runOnUiThread {
82
+ attachedView?.setOnApplyWindowInsetsListener(null)
83
+ attachedView = null
84
+ insetsListener = null
85
+ }
86
+ }
87
+
88
+ private fun emitSafeAreaInsetsIfChanged(view: View?) {
89
+ val target = view ?: return
90
+ val insets = getSafeAreaInsets(target) ?: return
91
+
92
+ if (lastInsets != null && areInsetsEqual(lastInsets!!, insets)) {
93
+ return
94
+ }
95
+
96
+ lastInsets = insets
97
+ sendEvent(
98
+ "onSafeAreaInsetsChange",
99
+ mapOf("insets" to insetsToMap(insets))
100
+ )
101
+ }
102
+
103
+ private fun areInsetsEqual(lhs: EdgeInsets, rhs: EdgeInsets): Boolean {
104
+ return lhs.top == rhs.top &&
105
+ lhs.right == rhs.right &&
106
+ lhs.bottom == rhs.bottom &&
107
+ lhs.left == rhs.left
108
+ }
109
+
110
+ private fun insetsToMap(insets: EdgeInsets): Map<String, Float> {
111
+ return mapOf(
112
+ "top" to insets.top,
113
+ "right" to insets.right,
114
+ "bottom" to insets.bottom,
115
+ "left" to insets.left
116
+ )
117
+ }
118
+ }
@@ -0,0 +1,87 @@
1
+ package expo.modules.rntoolscore
2
+
3
+ import android.graphics.Rect
4
+ import android.os.Build
5
+ import android.view.View
6
+ import android.view.WindowInsets
7
+ import androidx.annotation.RequiresApi
8
+ import kotlin.math.max
9
+ import kotlin.math.min
10
+
11
+ data class EdgeInsets(
12
+ val top: Float,
13
+ val right: Float,
14
+ val bottom: Float,
15
+ val left: Float
16
+ )
17
+
18
+ @RequiresApi(Build.VERSION_CODES.R)
19
+ private fun getRootWindowInsetsCompatR(rootView: View): EdgeInsets? {
20
+ val insets =
21
+ rootView.rootWindowInsets?.getInsets(
22
+ WindowInsets.Type.statusBars() or
23
+ WindowInsets.Type.displayCutout() or
24
+ WindowInsets.Type.navigationBars() or
25
+ WindowInsets.Type.captionBar()
26
+ ) ?: return null
27
+
28
+ return EdgeInsets(
29
+ top = insets.top.toFloat(),
30
+ right = insets.right.toFloat(),
31
+ bottom = insets.bottom.toFloat(),
32
+ left = insets.left.toFloat()
33
+ )
34
+ }
35
+
36
+ @RequiresApi(Build.VERSION_CODES.M)
37
+ @Suppress("DEPRECATION")
38
+ private fun getRootWindowInsetsCompatM(rootView: View): EdgeInsets? {
39
+ val insets = rootView.rootWindowInsets ?: return null
40
+ return EdgeInsets(
41
+ top = insets.systemWindowInsetTop.toFloat(),
42
+ right = insets.systemWindowInsetRight.toFloat(),
43
+ // Use the min to avoid including the keyboard while still honoring nav bars.
44
+ bottom = min(insets.systemWindowInsetBottom, insets.stableInsetBottom).toFloat(),
45
+ left = insets.systemWindowInsetLeft.toFloat()
46
+ )
47
+ }
48
+
49
+ private fun getRootWindowInsetsCompatBase(rootView: View): EdgeInsets? {
50
+ val visibleRect = Rect()
51
+ rootView.getWindowVisibleDisplayFrame(visibleRect)
52
+ return EdgeInsets(
53
+ top = visibleRect.top.toFloat(),
54
+ right = (rootView.width - visibleRect.right).toFloat(),
55
+ bottom = (rootView.height - visibleRect.bottom).toFloat(),
56
+ left = visibleRect.left.toFloat()
57
+ )
58
+ }
59
+
60
+ private fun getRootWindowInsetsCompat(rootView: View): EdgeInsets? {
61
+ return when {
62
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> getRootWindowInsetsCompatR(rootView)
63
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> getRootWindowInsetsCompatM(rootView)
64
+ else -> getRootWindowInsetsCompatBase(rootView)
65
+ }
66
+ }
67
+
68
+ fun getSafeAreaInsets(view: View): EdgeInsets? {
69
+ if (view.height == 0) {
70
+ return null
71
+ }
72
+
73
+ val rootView = view.rootView
74
+ val windowInsets = getRootWindowInsetsCompat(rootView) ?: return null
75
+
76
+ val windowWidth = rootView.width.toFloat()
77
+ val windowHeight = rootView.height.toFloat()
78
+ val visibleRect = Rect()
79
+ view.getGlobalVisibleRect(visibleRect)
80
+
81
+ return EdgeInsets(
82
+ top = max(windowInsets.top - visibleRect.top, 0f),
83
+ right = max(min(visibleRect.left + view.width - windowWidth, 0f) + windowInsets.right, 0f),
84
+ bottom = max(min(visibleRect.top + view.height - windowHeight, 0f) + windowInsets.bottom, 0f),
85
+ left = max(windowInsets.left - visibleRect.left, 0f)
86
+ )
87
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "platforms": [
3
+ "apple",
4
+ "android",
5
+ "web"
6
+ ],
7
+ "apple": {
8
+ "modules": [
9
+ "RNToolsCoreModule"
10
+ ]
11
+ },
12
+ "android": {
13
+ "modules": [
14
+ "expo.modules.rntoolscore.RNToolsCoreModule"
15
+ ]
16
+ }
17
+ }
@@ -0,0 +1,30 @@
1
+ require 'json'
2
+
3
+ ENV['RCT_NEW_ARCH_ENABLED'] ||= '1'
4
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
5
+ new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
6
+
7
+ Pod::Spec.new do |s|
8
+ s.name = 'RNToolsCore'
9
+ s.version = package['version']
10
+ s.summary = package['description']
11
+ s.description = package['description']
12
+ s.license = package['license']
13
+ s.author = package['author']
14
+ s.homepage = package['homepage']
15
+ s.platforms = {
16
+ :ios => '16.0',
17
+ :tvos => '16.0'
18
+ }
19
+ s.swift_version = '5.4'
20
+ s.source = { git: 'https://github.com/ajsmth/rn-tools' }
21
+ s.static_framework = true
22
+
23
+ s.dependency 'ExpoModulesCore'
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ 'OTHER_SWIFT_FLAGS' => '$(inherited)'
27
+ }
28
+
29
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
30
+ end
@@ -0,0 +1,110 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ public class RNToolsCoreModule: Module {
5
+ private var lastInsets: UIEdgeInsets?
6
+ private var observers: [NSObjectProtocol] = []
7
+
8
+ public func definition() -> ModuleDefinition {
9
+ Name("RNToolsCore")
10
+
11
+ Events("onSafeAreaInsetsChange")
12
+
13
+ Function("getSafeAreaInsets") { () -> [String: CGFloat] in
14
+ if Thread.isMainThread {
15
+ return self.safeAreaInsetsDictionary()
16
+ }
17
+
18
+ var result = self.safeAreaInsetsDictionary()
19
+ DispatchQueue.main.sync {
20
+ result = self.safeAreaInsetsDictionary()
21
+ }
22
+ return result
23
+ }
24
+
25
+ OnCreate {
26
+ self.startObservingSafeArea()
27
+ }
28
+
29
+ OnDestroy {
30
+ self.stopObservingSafeArea()
31
+ }
32
+
33
+ OnAppBecomesActive {
34
+ self.emitSafeAreaInsetsIfChanged()
35
+ }
36
+ }
37
+
38
+ private func startObservingSafeArea() {
39
+ UIDevice.current.beginGeneratingDeviceOrientationNotifications()
40
+ let center = NotificationCenter.default
41
+ let names: [NSNotification.Name] = [
42
+ UIApplication.didBecomeActiveNotification,
43
+ UIApplication.willEnterForegroundNotification,
44
+ UIDevice.orientationDidChangeNotification,
45
+ UIApplication.didChangeStatusBarFrameNotification,
46
+ ]
47
+
48
+ observers = names.map { name in
49
+ center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
50
+ self?.emitSafeAreaInsetsIfChanged()
51
+ }
52
+ }
53
+
54
+ emitSafeAreaInsetsIfChanged()
55
+ }
56
+
57
+ private func stopObservingSafeArea() {
58
+ let center = NotificationCenter.default
59
+ observers.forEach { center.removeObserver($0) }
60
+ observers.removeAll()
61
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
62
+ }
63
+
64
+ private func emitSafeAreaInsetsIfChanged() {
65
+ let send = { [weak self] in
66
+ guard let self else { return }
67
+ let windowInsets = self.keyWindow()?.safeAreaInsets ?? .zero
68
+ if let last = self.lastInsets, self.areInsetsEqual(last, windowInsets) {
69
+ return
70
+ }
71
+ self.lastInsets = windowInsets
72
+ self.sendEvent("onSafeAreaInsetsChange", ["insets": self.safeAreaInsetsDictionary(windowInsets)])
73
+ }
74
+
75
+ if Thread.isMainThread {
76
+ send()
77
+ } else {
78
+ DispatchQueue.main.async { send() }
79
+ }
80
+ }
81
+
82
+ private func safeAreaInsetsDictionary(_ insets: UIEdgeInsets? = nil) -> [String: CGFloat] {
83
+ let resolved = insets ?? keyWindow()?.safeAreaInsets ?? .zero
84
+ return [
85
+ "top": resolved.top,
86
+ "right": resolved.right,
87
+ "bottom": resolved.bottom,
88
+ "left": resolved.left,
89
+ ]
90
+ }
91
+
92
+ private func areInsetsEqual(_ lhs: UIEdgeInsets, _ rhs: UIEdgeInsets) -> Bool {
93
+ return lhs.top == rhs.top &&
94
+ lhs.right == rhs.right &&
95
+ lhs.bottom == rhs.bottom &&
96
+ lhs.left == rhs.left
97
+ }
98
+
99
+ private func keyWindow() -> UIWindow? {
100
+ let scenes = UIApplication.shared.connectedScenes
101
+ .compactMap { $0 as? UIWindowScene }
102
+ .flatMap { $0.windows }
103
+
104
+ if let window = scenes.first(where: { $0.isKeyWindow }) {
105
+ return window
106
+ }
107
+
108
+ return UIApplication.shared.windows.first(where: { $0.isKeyWindow })
109
+ }
110
+ }
@@ -0,0 +1,21 @@
1
+ export const Platform = {
2
+ OS: "ios",
3
+ select: (options: Record<string, unknown>) => options.ios,
4
+ };
5
+
6
+ export const Keyboard = {
7
+ addListener: () => ({ remove: () => {} }),
8
+ };
9
+
10
+ export const StyleSheet = {
11
+ absoluteFill: {},
12
+ create: (styles: Record<string, unknown>) => styles,
13
+ };
14
+
15
+ export function View(props: { children?: unknown }) {
16
+ return props.children;
17
+ }
18
+
19
+ export function Text(props: { children?: unknown }) {
20
+ return props.children;
21
+ }
@@ -0,0 +1,50 @@
1
+ import * as React from "react";
2
+ import {
3
+ type RenderNode,
4
+ getRenderNode,
5
+ getRenderNodeActive,
6
+ getRenderNodeDepth,
7
+ getRenderNodeChildren,
8
+ getRenderNodeParent,
9
+ useRenderTreeSelector,
10
+ } from "../src/render-tree";
11
+
12
+ export type RenderNodeProbeData = {
13
+ node: RenderNode;
14
+ type: RenderNode["type"];
15
+ active: boolean;
16
+ depth: number;
17
+ parent: RenderNode | null;
18
+ children: RenderNode[];
19
+ };
20
+
21
+ export function RenderNodeProbe(props: {
22
+ render: (data: RenderNodeProbeData) => React.ReactNode;
23
+ }) {
24
+ const data = useRenderTreeSelector((chart, id) => {
25
+ const node = getRenderNode(chart, id);
26
+ if (!node) {
27
+ return null;
28
+ }
29
+
30
+ const parent = getRenderNodeParent(chart, id);
31
+ const children = getRenderNodeChildren(chart, id);
32
+ const depth = getRenderNodeDepth(chart, id);
33
+ const active = getRenderNodeActive(chart, id);
34
+
35
+ return {
36
+ node,
37
+ type: node.type,
38
+ active,
39
+ depth,
40
+ parent,
41
+ children,
42
+ } satisfies RenderNodeProbeData;
43
+ });
44
+
45
+ if (!data) {
46
+ return null;
47
+ }
48
+
49
+ return <>{props.render(data)}</>;
50
+ }
package/mocks/setup.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { afterEach } from "vitest";
2
+ import { cleanup } from "@testing-library/react";
3
+
4
+ afterEach(() => {
5
+ cleanup();
6
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@rn-tools/core",
3
+ "version": "3.0.1",
4
+ "description": "Core utilities for rn-tools",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "test": "vitest"
8
+ },
9
+ "keywords": [
10
+ "react-native",
11
+ "expo",
12
+ "core",
13
+ "RNToolsCore"
14
+ ],
15
+ "repository": "https://github.com/ajsmth/rn-tools/tree/main/packages/core",
16
+ "bugs": {
17
+ "url": "https://github.com/ajsmth/rn-tools/issues"
18
+ },
19
+ "author": "andy <andydevs123@gmail.com> (ajsmth)",
20
+ "license": "MIT",
21
+ "homepage": "https://github.com/ajsmth/rn-tools#readme",
22
+ "devDependencies": {
23
+ "@testing-library/react": "^14.2.1",
24
+ "@types/react": "18.3.12",
25
+ "@types/react-test-renderer": "^18.0.0",
26
+ "react-test-renderer": "^18.2.0",
27
+ "vitest": "^1.6.0"
28
+ },
29
+ "dependencies": {
30
+ "use-sync-external-store": "^1.2.2"
31
+ },
32
+ "peerDependencies": {
33
+ "expo": "*",
34
+ "react": "*",
35
+ "react-native": "*"
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./safe-area";
2
+ export * from "./keyboard";
3
+ export * from "./store";
4
+ export * from "./render-tree";
@@ -0,0 +1,43 @@
1
+ import { Keyboard, KeyboardEvent } from "react-native";
2
+ import { createStore, useStore } from "./store";
3
+
4
+ export type KeyboardState = {
5
+ height: number;
6
+ };
7
+
8
+ const fallbackHeight = 0;
9
+
10
+ export function getKeyboardHeight(): number {
11
+ return fallbackHeight;
12
+ }
13
+
14
+ export const keyboardHeightStore = createStore<KeyboardState>({
15
+ height: getKeyboardHeight(),
16
+ });
17
+
18
+ const updateHeight = (height: number) => {
19
+ keyboardHeightStore.setState((state) => {
20
+ if (state.height === height) {
21
+ return state;
22
+ }
23
+ return { ...state, height };
24
+ });
25
+ };
26
+
27
+ const handleShow = (event: KeyboardEvent) => {
28
+ const nextHeight = event.endCoordinates?.height ?? 0;
29
+ updateHeight(nextHeight);
30
+ };
31
+
32
+ const handleHide = () => {
33
+ updateHeight(0);
34
+ };
35
+
36
+ Keyboard.addListener("keyboardWillShow", handleShow);
37
+ Keyboard.addListener("keyboardWillHide", handleHide);
38
+ Keyboard.addListener("keyboardDidShow", handleShow);
39
+ Keyboard.addListener("keyboardDidHide", handleHide);
40
+
41
+ export const useKeyboardHeight = (): number => {
42
+ return useStore(keyboardHeightStore, (state) => state.height);
43
+ };