feat: use expo module to write native component/module (#2675)
* init Signed-off-by: Innei <tukon479@gmail.com> * test uikit Signed-off-by: Innei <tukon479@gmail.com> * uitable Signed-off-by: Innei <tukon479@gmail.com> * test webview Signed-off-by: Innei <tukon479@gmail.com> * shared webview Signed-off-by: Innei <tukon479@gmail.com> * refactor(shared-webview): Simplify WebView height management Signed-off-by: Innei <tukon479@gmail.com> * fix: add url prop Signed-off-by: Innei <tukon479@gmail.com> * rename Signed-off-by: Innei <tukon479@gmail.com> * feat: native module Signed-off-by: Innei <tukon479@gmail.com> * cleanup Signed-off-by: Innei <tukon479@gmail.com> * fix: export Signed-off-by: Innei <tukon479@gmail.com> * fix Signed-off-by: Innei <tukon479@gmail.com> * fix Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
86ed6d4eed
commit
e1bf881e85
|
|
@ -23,3 +23,6 @@ vite.config.*.mjs
|
|||
|
||||
apps/renderer/dev-dist
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
apps/mobile/ios
|
||||
apps/mobile/android
|
||||
|
|
|
|||
|
|
@ -74,5 +74,7 @@
|
|||
"i18n-ally.displayLanguage": "en",
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.namespace": true,
|
||||
"i18n-ally.pathMatcher": "{namespaces}/{locale}.json"
|
||||
"i18n-ally.pathMatcher": "{namespaces}/{locale}.json",
|
||||
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||
"lldb.launch.expressions": "native"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,5 +36,3 @@ yarn-error.*
|
|||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
ios
|
||||
android
|
||||
|
|
@ -5,6 +5,17 @@ const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro
|
|||
const config = getDefaultConfig(__dirname, { isCSSEnabled: true })
|
||||
config.resolver.sourceExts.push("sql")
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
"follow-native": "./native",
|
||||
}
|
||||
|
||||
config.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = wrapWithReanimatedMetroConfig(
|
||||
withNativeWind(config, { input: "./src/global.css" }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
extends: ["universe/native", "universe/web"],
|
||||
ignorePatterns: ["build"],
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
jsconfig.json
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
|
||||
# Android/IJ
|
||||
#
|
||||
.classpath
|
||||
.cxx
|
||||
.gradle
|
||||
.idea
|
||||
.project
|
||||
.settings
|
||||
local.properties
|
||||
android.iml
|
||||
android/app/libs
|
||||
android/keystores/debug.keystore
|
||||
|
||||
# Cocoapods
|
||||
#
|
||||
example/ios/Pods
|
||||
|
||||
# Ruby
|
||||
example/vendor/
|
||||
|
||||
# node.js
|
||||
#
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Expo
|
||||
.expo/*
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Exclude all top-level hidden directories by convention
|
||||
/.*/
|
||||
|
||||
# Exclude tarballs generated by `npm pack`
|
||||
/*.tgz
|
||||
|
||||
__mocks__
|
||||
__tests__
|
||||
|
||||
/babel.config.js
|
||||
/android/src/androidTest/
|
||||
/android/src/test/
|
||||
/android/build/
|
||||
/example/
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# follow-native
|
||||
|
||||
Nope
|
||||
|
||||
# API documentation
|
||||
|
||||
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/follow-native/)
|
||||
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/follow-native/)
|
||||
|
||||
# Installation in managed Expo projects
|
||||
|
||||
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
||||
|
||||
# Installation in bare React Native projects
|
||||
|
||||
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
||||
|
||||
### Add the package to your npm dependencies
|
||||
|
||||
```
|
||||
npm install follow-native
|
||||
```
|
||||
|
||||
### Configure for Android
|
||||
|
||||
### Configure for iOS
|
||||
|
||||
Run `npx pod-install` after installing the npm package.
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.follownative'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.follownative"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<manifest>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package expo.modules.follownative
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.net.URL
|
||||
|
||||
class FollowNativeModule : Module() {
|
||||
// Each module class must implement the definition function. The definition consists of components
|
||||
// that describes the module's functionality and behavior.
|
||||
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
||||
override fun definition() = ModuleDefinition {
|
||||
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
||||
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
||||
// The module will be accessible from `requireNativeModule('FollowNative')` in JavaScript.
|
||||
Name("FollowNative")
|
||||
|
||||
// Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary.
|
||||
Constants(
|
||||
"PI" to Math.PI
|
||||
)
|
||||
|
||||
// Defines event names that the module can send to JavaScript.
|
||||
Events("onChange")
|
||||
|
||||
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||
Function("hello") {
|
||||
"Hello world! 👋"
|
||||
}
|
||||
|
||||
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||
AsyncFunction("setValueAsync") { value: String ->
|
||||
// Send an event to JavaScript.
|
||||
sendEvent("onChange", mapOf(
|
||||
"value" to value
|
||||
))
|
||||
}
|
||||
|
||||
// Enables the module to be used as a native view. Definition components that are accepted as part of
|
||||
// the view definition: Prop, Events.
|
||||
View(FollowNativeView::class) {
|
||||
// Defines a setter for the `url` prop.
|
||||
Prop("url") { view: FollowNativeView, url: URL ->
|
||||
view.webView.loadUrl(url.toString())
|
||||
}
|
||||
// Defines an event that the view can send to JavaScript.
|
||||
Events("onLoad")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package expo.modules.follownative
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class FollowNativeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
// Creates and initializes an event dispatcher for the `onLoad` event.
|
||||
// The name of the event is inferred from the value and needs to match the event name defined in the module.
|
||||
private val onLoad by EventDispatcher()
|
||||
|
||||
// Defines a WebView that will be used as the root subview.
|
||||
internal val webView = WebView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
|
||||
onLoad(mapOf("url" to url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Adds the WebView to the view hierarchy.
|
||||
addView(webView)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Expo
|
||||
.expo
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
ios
|
||||
android
|
||||
|
|
@ -0,0 +1 @@
|
|||
node-linker=hoisted
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { SharedWebView } from "follow-native"
|
||||
import { ScrollView, View } from "react-native"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ScrollView>
|
||||
<SharedWebView url="https://innei.in" />
|
||||
|
||||
<View style={{ height: 1000, backgroundColor: "red" }} />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "follow-native-example",
|
||||
"slug": "follow-native-example",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "expo.modules.follownative.example"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "expo.modules.follownative.example"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { registerRootComponent } from "expo"
|
||||
|
||||
import App from "./App"
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config")
|
||||
const path = require("node:path")
|
||||
|
||||
const config = getDefaultConfig(__dirname)
|
||||
|
||||
// npm v7+ will install ../node_modules/react and ../node_modules/react-native because of peerDependencies.
|
||||
// To prevent the incompatible react-native between ./node_modules/react-native and ../node_modules/react-native,
|
||||
// excludes the one from the parent folder when bundling.
|
||||
config.resolver.blockList = [
|
||||
...Array.from(config.resolver.blockList ?? []),
|
||||
new RegExp(path.resolve("..", "node_modules", "react")),
|
||||
new RegExp(path.resolve("..", "node_modules", "react-native")),
|
||||
]
|
||||
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../node_modules"),
|
||||
]
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
"follow-native": "..",
|
||||
}
|
||||
|
||||
config.watchFolders = [path.resolve(__dirname, "..")]
|
||||
|
||||
config.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = config
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "follow-native-example",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"start": "expo start",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "^52.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
"nativeModulesDir": ".."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"follow-native": ["../src/index"],
|
||||
"follow-native/*": ["../src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const createConfigAsync = require("@expo/webpack-config")
|
||||
const path = require("node:path")
|
||||
|
||||
module.exports = async (env, argv) => {
|
||||
const config = await createConfigAsync(
|
||||
{
|
||||
...env,
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: ["follow-native"],
|
||||
},
|
||||
},
|
||||
argv,
|
||||
)
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, "./node_modules"),
|
||||
path.resolve(__dirname, "../node_modules"),
|
||||
]
|
||||
|
||||
return config
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"platforms": ["apple", "android"],
|
||||
"apple": {
|
||||
"modules": ["SharedWebViewModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// https://github.com/expo/expo/blob/main/packages/expo-modules-core/ios/Core/Views/SwiftUI/AutoSizingStack.swift#L18
|
||||
import SwiftUI
|
||||
import ExpoModulesCore
|
||||
extension ExpoSwiftUI {
|
||||
public struct AxisSet: OptionSet {
|
||||
public init(rawValue: Int) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
public let rawValue: Int
|
||||
|
||||
public static let horizontal = AxisSet(rawValue: 1 << 0)
|
||||
public static let vertical = AxisSet(rawValue: 1 << 1)
|
||||
|
||||
public static let both: AxisSet = [.horizontal, .vertical]
|
||||
}
|
||||
|
||||
public struct AutoSizingStack<Content: SwiftUI.View>: SwiftUI.View {
|
||||
let content: Content
|
||||
let proxy: ShadowNodeProxy
|
||||
let axis: AxisSet
|
||||
|
||||
public init(shadowNodeProxy: ShadowNodeProxy, axis: AxisSet = .both, @ViewBuilder _ content: () -> Content) {
|
||||
self.proxy = shadowNodeProxy
|
||||
self.content = content()
|
||||
self.axis = axis
|
||||
}
|
||||
|
||||
public var body: some SwiftUI.View {
|
||||
if #available(iOS 16.0, tvOS 16.0, *) {
|
||||
content.fixedSize(horizontal: axis.contains(.horizontal), vertical: axis.contains(.vertical))
|
||||
.onGeometryChange(for: CGSize.self) { proxy in
|
||||
proxy.size
|
||||
} action: {
|
||||
let width = axis.contains(.horizontal) ? $0.width : ShadowNodeProxy.UNDEFINED_SIZE
|
||||
let height = axis.contains(.vertical) ? $0.height : ShadowNodeProxy.UNDEFINED_SIZE
|
||||
let size = CGSize(width: width, height: height)
|
||||
proxy.setViewSize?(size)
|
||||
}
|
||||
} else {
|
||||
// TODO: throw a warning
|
||||
content.onAppear(perform: {
|
||||
log.warn("AutoSizingStack is not supported on iOS/tvOS < 16.0")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ExpoSwiftUI {
|
||||
open class ShadowNodeProxy: ObservableObject, Record {
|
||||
public required init() {}
|
||||
|
||||
// We use Double.nan to mark a dimension as unset. This value is passed to https://github.com/facebook/yoga/blob/49ee855f99fb67079c24d507a4ea1b6d80fa2ebf/yoga/style/StyleLength.h#L33
|
||||
static let UNDEFINED_SIZE = Double.nan
|
||||
|
||||
public var setViewSize: ((CGSize) -> Void)?
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'FollowNative'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.description = package['description']
|
||||
s.license = package['license']
|
||||
s.author = package['author']
|
||||
s.homepage = package['homepage']
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.swift_version = '5.4'
|
||||
s.source = { git: 'https://github.com/Innei/follow-native' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'SnapKit', '~> 5.7.0'
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import Combine
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
class WebViewView: ExpoView {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
private let rctView = ExpoModulesCore.RCTView(frame: .zero)
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
addSubview(rctView)
|
||||
|
||||
#if DEBUG
|
||||
rctView.borderStyle = .solid
|
||||
rctView.borderWidth = 1
|
||||
rctView.borderColor = .tintColor
|
||||
#endif
|
||||
rctView.addSubview(SharedWebViewModule.sharedWebView!)
|
||||
|
||||
clipsToBounds = true
|
||||
|
||||
cancellable = WebViewManager.state.$contentHeight
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.layoutSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
|
||||
private let onContentHeightChange = ExpoModulesCore.EventDispatcher()
|
||||
|
||||
override func layoutSubviews() {
|
||||
let rect = CGRect(
|
||||
x: bounds.origin.x,
|
||||
y: bounds.origin.y,
|
||||
width: bounds.width,
|
||||
height: WebViewManager.state.contentHeight
|
||||
)
|
||||
|
||||
SharedWebViewModule.sharedWebView!.frame = rect
|
||||
|
||||
frame = rect
|
||||
|
||||
rctView.frame = rect
|
||||
|
||||
onContentHeightChange(["height": Float(rect.height)])
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct WebViewComponent: UIViewRepresentable {
|
||||
let webView: WKWebView
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
// Created by Innei on 2025/1/29.
|
||||
//
|
||||
|
||||
import ExpoModulesCore
|
||||
import WebKit
|
||||
|
||||
public class SharedWebViewModule: Module {
|
||||
public static var sharedWebView: WKWebView? {
|
||||
WebViewManager.shared
|
||||
}
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("FOSharedWebView")
|
||||
|
||||
Function("preload") { (url: String) in
|
||||
DispatchQueue.main.async {
|
||||
guard let webUrl = URL(string: url) else { return }
|
||||
let request = URLRequest(url: webUrl)
|
||||
WebViewManager.shared.load(request)
|
||||
}
|
||||
}
|
||||
|
||||
View(WebViewView.self) {
|
||||
Events("onContentHeightChange")
|
||||
|
||||
Prop("url") { (_: UIView, urlString: String) in
|
||||
if let webView = SharedWebViewModule.sharedWebView {
|
||||
if let url = URL(string: urlString) {
|
||||
if url == webView.url {
|
||||
return
|
||||
}
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//
|
||||
// WebViewManager.swift
|
||||
// FollowNative
|
||||
//
|
||||
// Created by Innei on 2025/1/31.
|
||||
//
|
||||
import Combine
|
||||
import ExpoModulesCore
|
||||
import WebKit
|
||||
|
||||
enum WebViewManager {
|
||||
static let state = WebViewState()
|
||||
|
||||
static private(set) var shared: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
|
||||
let css = """
|
||||
:root { overflow: hidden !important; }
|
||||
body {
|
||||
overflow-y: visible !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
"""
|
||||
|
||||
let script = WKUserScript(
|
||||
source: """
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '\(css)';
|
||||
document.head.appendChild(style);
|
||||
""",
|
||||
injectionTime: .atDocumentEnd,
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
|
||||
configuration.userContentController.addUserScript(script)
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
|
||||
|
||||
#if DEBUG
|
||||
if #available(iOS 16.4, *) {
|
||||
|
||||
webView.isInspectable = true
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
setupWebView(webView)
|
||||
|
||||
return webView
|
||||
}()
|
||||
|
||||
private static func setupWebView(_ webView: WKWebView) {
|
||||
let delegate = WebViewDelegate(state: state)
|
||||
webView.navigationDelegate = delegate
|
||||
|
||||
let script = WKUserScript(
|
||||
source: """
|
||||
window.addEventListener('load', function() {
|
||||
window.webkit.messageHandlers.contentHeight.postMessage(document.documentElement.scrollHeight);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(function() {
|
||||
window.webkit.messageHandlers.contentHeight.postMessage(document.documentElement.scrollHeight);
|
||||
});
|
||||
observer.observe(document.documentElement);
|
||||
""",
|
||||
injectionTime: .atDocumentEnd,
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
|
||||
webView.configuration.userContentController.addUserScript(script)
|
||||
webView.configuration.userContentController.add(delegate, name: "contentHeight")
|
||||
}
|
||||
|
||||
static func resetWebView() {
|
||||
let newWebView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
setupWebView(newWebView)
|
||||
shared = newWebView
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - WebViewState
|
||||
private class WebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
private let state: WebViewState
|
||||
|
||||
init(state: WebViewState) {
|
||||
self.state = state
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if message.name == "contentHeight", let height = message.body as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
|
||||
self.state.contentHeight = height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
webView.evaluateJavaScript("document.documentElement.scrollHeight") { (height, error) in
|
||||
if let height = height as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
self.state.contentHeight = height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// WebViewState.swift
|
||||
// FollowNative
|
||||
//
|
||||
// Created by Innei on 2025/1/31.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
class WebViewState: ObservableObject {
|
||||
@Published var contentHeight: CGFloat = UIWindow().bounds.height
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "follow-native",
|
||||
"version": "0.1.0",
|
||||
"description": "Nope",
|
||||
"author": "Innei <tukon479@gmail.com> (https://github.com/Innei)",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/Innei/follow-native#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Innei/follow-native/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"react-native",
|
||||
"expo",
|
||||
"follow-native",
|
||||
"FollowNative"
|
||||
],
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"//prepare": "expo-module prepare",
|
||||
"build": "expo-module build",
|
||||
"clean": "expo-module clean",
|
||||
"expo-module": "expo-module",
|
||||
"lint": "expo-module lint",
|
||||
"open:android": "open -a \"Android Studio\" example/android",
|
||||
"open:ios": "xed example/ios",
|
||||
"prepublishOnly": "expo-module prepublishOnly"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
"expo": "~52.0.0",
|
||||
"expo-module-scripts": "^4.0.3",
|
||||
"react-native": "0.76.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { requireNativeView } from "expo"
|
||||
import type * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { View } from "react-native"
|
||||
|
||||
const NativeView: React.ComponentType<{
|
||||
onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void
|
||||
url: string
|
||||
}> = requireNativeView("FOSharedWebView")
|
||||
|
||||
export default function SharedWebView({ url }: { url: string }) {
|
||||
const [contentHeight, setContentHeight] = useState(0)
|
||||
|
||||
return (
|
||||
<View style={{ height: contentHeight }}>
|
||||
<NativeView
|
||||
onContentHeightChange={(e) => {
|
||||
setContentHeight(e.nativeEvent.height)
|
||||
}}
|
||||
url={url}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { NativeModule } from "expo"
|
||||
import { requireNativeModule } from "expo-modules-core"
|
||||
|
||||
export { default as SharedWebView } from "./SharedWebView"
|
||||
|
||||
declare class ISharedWebViewModule extends NativeModule {
|
||||
preload(url: string): void
|
||||
}
|
||||
|
||||
export const SharedWebViewModule = requireNativeModule<ISharedWebViewModule>("FOSharedWebView")
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// @generated by expo-module-scripts
|
||||
{
|
||||
"extends": "expo-module-scripts/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "./build"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
|
||||
}
|
||||
|
|
@ -119,6 +119,9 @@
|
|||
"react-native-safe-area-context@~5.1.0",
|
||||
"react-native-webview@~13.12.5"
|
||||
]
|
||||
},
|
||||
"autolinking": {
|
||||
"nativeModulesDir": "./native"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"follow-native": ["./native/src/index.ts"],
|
||||
"follow-native/*": ["./native/*"]
|
||||
},
|
||||
"jsx": "preserve"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export default defineConfig(
|
|||
"package-json-extend": packageJsonExtend,
|
||||
},
|
||||
rules: {
|
||||
"package-json-extend/ensure-package-version": "error",
|
||||
"package-json-extend/ensure-package-version": "warn",
|
||||
"package-json-extend/no-duplicate-package": "error",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -148,8 +148,10 @@
|
|||
"overrides": {
|
||||
"@types/react": "npm:@types/react@^18.3.12",
|
||||
"@types/react-dom": "npm:@types/react-dom@^18.3.1",
|
||||
"expo-modules-core": "npm:expo-modules-core@2.2.0",
|
||||
"is-core-module": "npm:@nolyfill/is-core-module@^1",
|
||||
"isarray": "npm:@nolyfill/isarray@^1",
|
||||
"react-native": "npm:react-native@0.77.0",
|
||||
"unist-util-visit-parents": "5.1.3",
|
||||
"vfile": "5.3.7"
|
||||
}
|
||||
|
|
|
|||
2734
pnpm-lock.yaml
2734
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,3 +1,6 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "apps/mobile/native"
|
||||
- "!**/example"
|
||||
- "!**/example/*"
|
||||
|
|
|
|||
Loading…
Reference in New Issue