move into repo

This commit is contained in:
Hailey
2024-10-02 17:53:05 -07:00
parent 24bebecf6b
commit d007151f49
16 changed files with 781 additions and 6 deletions
+56
View File
@@ -0,0 +1,56 @@
apply plugin: 'com.android.library'
group = 'expo.modules.bottomsheet'
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.bottomsheet"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
dependencies {
implementation project(':expo-modules-core')
implementation "androidx.compose.material3:material3:1.3.0"
implementation "androidx.compose.material:material:1.7.2"
implementation "com.facebook.react:react-native:+"
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,133 @@
package expo.modules.bottomsheet
import android.content.Context
import android.view.View
import android.widget.FrameLayout
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.allViews
import com.facebook.react.ReactRootView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class BottomSheetView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
val sheetState = mutableStateOf(SheetState())
private var reactRootView: ReactRootView? = null
private var innerView: View? = null
private var sheetView: ComposeView? = null
private val onStateChange by EventDispatcher()
private val onAttemptDismiss by EventDispatcher()
// Props
var preventDismiss = false
var minHeight = 0f
var maxHeight = 0f
private var isOpen: Boolean = false
set(value) {
if (field == value) return
field = value
this.sheetState.value.isOpen = value
onStateChange(mapOf(
"state" to if (value) "open" else "closed"
))
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "opening"))
}
}
private var isClosing: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "closing"))
}
}
private var hasInitiallyOpened = false
// Lifecycle
override fun addView(child: View?, index: Int) {
this.innerView = child
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
this.innerView?.let {
val height = it.allViews.last().measuredHeight.toFloat()
this.present(height)
}
}
private fun destroy() {
this.isClosing = false
this.isOpen = false
this.getRootLayout().removeView(this.sheetView)
this.sheetView = null
this.reactRootView = null
this.innerView = null
}
// Presentation
private fun present(contentHeight: Float) {
val innerView = this.innerView ?: return
// For GestureRootView to work, we need to create a ReactRootView for the innerView to be
// contained inside of
val reactRootView = ReactRootView(context)
reactRootView.addView(innerView)
this.reactRootView = reactRootView
this.isOpening = true
this.sheetView = ComposeView(context).also {
it.setContent {
SheetView(
state = sheetState,
innerView = reactRootView,
contentHeight = innerView.height.toFloat(),
onDismissRequest = {
onAttemptDismiss(mapOf())
if (!preventDismiss) {
dismiss()
}
},
onExpanded = {
isOpening = false
isOpen = true
hasInitiallyOpened = true
},
onHidden = {
if (hasInitiallyOpened) {
destroy()
}
}
)
}
getRootLayout().addView(it)
}
}
fun dismiss() {
this.isClosing = true
this.destroy()
}
// Utils
private fun getRootLayout(): FrameLayout {
return appContext.currentActivity!!.findViewById(android.R.id.content)
}
}
@@ -0,0 +1,50 @@
package expo.modules.bottomsheet
import android.graphics.Color
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BottomSheetModule : Module() {
override fun definition() = ModuleDefinition {
Name("BlueskyBottomSheet")
Function("getSafeAreaInset") {
return@Function 10 // @TODO
}
View(BottomSheetView::class) {
Events(arrayOf(
"onStateChange",
"onAttemptDismiss",
))
AsyncFunction("dismiss") { view: BottomSheetView ->
view.dismiss()
}
Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
view.preventDismiss = prop
}
Prop("minHeight") { view: BottomSheetView, prop: Float ->
view.minHeight = prop
}
Prop("maxHeight") { view: BottomSheetView, prop: Float ->
view.maxHeight = prop
}
Prop("cornerRadius") { view: BottomSheetView, prop: Float ->
view.sheetState.value.cornerRadius = prop
}
Prop("containerBackgroundColor") { view: BottomSheetView, prop: String ->
view.sheetState.value.containerBackgroundColor = Color.parseColor(prop)
}
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.sheetState.value.preventExpansion = prop
}
}
}
}
@@ -0,0 +1,68 @@
package expo.modules.bottomsheet
import android.view.View
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
data class SheetState(
var isOpen: Boolean = false,
var cornerRadius: Float? = null,
var containerBackgroundColor: Int ? = null,
var preventExpansion: Boolean = false,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetView(
state: MutableState<SheetState>,
innerView: View,
contentHeight: Float,
onDismissRequest: () -> Unit,
onExpanded: () -> Unit,
onHidden: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(
topStart = state.value.cornerRadius ?: 0f,
topEnd = state.value.cornerRadius ?: 0f,
),
containerColor = Color(state.value.containerBackgroundColor ?: android.graphics.Color.TRANSPARENT),
) {
Column(
Modifier.fillMaxWidth()
.height(contentHeight.dp)
// Prevent covering up the handle
.padding(top = 34.dp)
) {
AndroidView(
factory = { innerView }
)
}
}
LaunchedEffect(sheetState.currentValue) {
if (sheetState.currentValue == SheetValue.PartiallyExpanded || sheetState.currentValue == SheetValue.Expanded) {
onExpanded()
} else if (sheetState.currentValue == SheetValue.Hidden) {
onHidden()
}
}
}
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'BottomSheet'
s.version = '1.0.0'
s.summary = 'A bottom sheet for use in Bluesky'
s.description = 'A bottom sheet for use in Bluesky'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.0', :tvos => '15.0' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end
@@ -0,0 +1,46 @@
import ExpoModulesCore
public class BottomSheetModule: Module {
public func definition() -> ModuleDefinition {
Name("BottomSheet")
AsyncFunction("dismissAll") {
SheetManager.shared.dismissAll()
}
View(SheetView.self) {
Events([
"onStateChange",
"onAttemptDismiss"
])
AsyncFunction("dismiss") { (view: SheetView) in
view.dismiss()
}
AsyncFunction("updateLayout") { (view: SheetView) in
view.updateLayout()
}
Prop("cornerRadius") { (view: SheetView, prop: Float) in
view.cornerRadius = CGFloat(prop)
}
Prop("preventDismiss") { (view: SheetView, prop: Bool) in
view.preventDismiss = prop
}
Prop("minHeight") { (view: SheetView, prop: Double) in
view.minHeight = prop
}
Prop("maxHeight") { (view: SheetView, prop: Double) in
view.maxHeight = prop
}
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
}
}
}
@@ -0,0 +1,28 @@
//
// SheetManager.swift
// Pods
//
// Created by Hailey on 10/1/24.
//
import ExpoModulesCore
class SheetManager {
static let shared = SheetManager()
private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
func add(_ view: SheetView) {
sheetViews.add(view)
}
func remove(_ view: SheetView) {
sheetViews.remove(view)
}
func dismissAll() {
sheetViews.allObjects.forEach { sheetView in
sheetView.dismiss()
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import ExpoModulesCore
import UIKit
class SheetView: ExpoView, UISheetPresentationControllerDelegate {
// Views
private var sheetVc: SheetViewController?
private var innerView: UIView?
// Events
private let onStateChange = EventDispatcher()
private let onAttemptDismiss = EventDispatcher()
// Open event firing
private var isOpen: Bool = false {
didSet {
onStateChange([
"state": isOpen ? "open" : "closed"
])
}
}
// React view props
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
let screenHeight = Util.getScreenHeight() ?? 0
if maxHeight > screenHeight {
maxHeight = screenHeight
}
}
}
private var isOpening = false {
didSet {
if isOpening {
onStateChange([
"state": "opening"
])
}
}
}
private var isClosing = false {
didSet {
if isClosing {
onStateChange([
"state": "closing"
])
}
}
}
// MARK: - Lifecycle
required init (appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.maxHeight = Util.getScreenHeight()
SheetManager.shared.add(self)
}
deinit {
self.destroy()
}
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
// to the SheetViewController
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
self.innerView = subview
}
// We'll grab the content height from here so we know the initial detent to set
override func layoutSubviews() {
super.layoutSubviews()
guard let innerView = self.innerView else {
return
}
if innerView.subviews.count != 1 {
return
}
self.present(contentHeight: innerView.subviews[0].frame.size.height)
}
private func destroy() {
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
self.innerView = nil
SheetManager.shared.remove(self)
}
// MARK: - Presentation
func present(contentHeight: CGFloat) {
guard !self.isOpen,
let innerView = self.innerView,
let rvc = self.reactViewController() else {
return
}
let sheetVc = SheetViewController()
if let sheet = sheetVc.sheetPresentationController {
sheet.delegate = self
sheet.preferredCornerRadius = self.cornerRadius
}
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
sheetVc.view.addSubview(innerView)
self.sheetVc = sheetVc
self.isOpening = true
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
self?.isOpen = true
}
}
func updateLayout() {
if let contentHeight = self.innerView?.subviews[0].frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
}
}
func dismiss() {
self.isClosing = true
self.sheetVc?.dismiss(animated: true) { [weak self] in
self?.destroy()
}
}
// MARK: - Utils
private func clampHeight(_ height: CGFloat) -> CGFloat {
if height < self.minHeight {
return self.minHeight
} else if height > self.maxHeight {
return self.maxHeight
}
return height
}
// MARK: - UISheetPresentationControllerDelegate
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
self.onAttemptDismiss()
return !self.preventDismiss
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.isClosing = true
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
self.destroy()
}
}
@@ -0,0 +1,67 @@
//
// SheetViewController.swift
// Pods
//
// Created by Hailey on 9/30/24.
//
import Foundation
import UIKit
class SheetViewController: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .formSheet
self.isModalInPresentation = false
if let sheet = self.sheetPresentationController {
sheet.prefersGrabberVisible = true
}
}
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
guard let sheet = self.sheetPresentationController,
let screenHeight = Util.getScreenHeight()
else {
return
}
if contentHeight > screenHeight {
sheet.detents = [
.large()
]
} else {
if #available(iOS 16.0, *) {
sheet.detents = [
.custom { _ in
return contentHeight
}
]
} else {
sheet.detents = [
.medium()
]
}
}
if !preventExpansion {
sheet.detents.append(.large())
}
}
func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
if let sheet = self.sheetPresentationController {
sheet.animateChanges {
self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
if #available(iOS 16.0, *) {
sheet.invalidateDetents()
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
+18
View File
@@ -0,0 +1,18 @@
//
// Util.swift
// Pods
//
// Created by Hailey on 10/2/24.
//
class Util {
static func getScreenHeight() -> CGFloat? {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
let safeAreaInsets = window.safeAreaInsets
let fullScreenHeight = UIScreen.main.bounds.height
return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
}
return nil
}
}
@@ -0,0 +1,22 @@
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
export interface BottomSheetViewProps {
children: React.ReactNode
cornerRadius?: number
preventDismiss?: boolean
preventExpansion?: boolean
containerBackgroundColor?: ColorValue
topInset?: number
bottomInset?: number
minHeight?: number
maxHeight?: number
onStateChange?: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
onAttemptDismiss?: (event: NativeSyntheticEvent<object>) => void
}
@@ -0,0 +1,104 @@
import * as React from 'react'
import {
ColorValue,
Dimensions,
NativeSyntheticEvent,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {requireNativeViewManager} from 'expo-modules-core'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
const screenHeight = Dimensions.get('screen').height
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BlueskyBottomSheet')
export class BottomSheetView extends React.Component<
BottomSheetViewProps,
{
open: boolean
}
> {
ref = React.createRef<any>()
constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
open: false,
}
}
present() {
this.setState({open: true})
}
dismiss() {
this.ref.current?.dismiss()
}
private onStateChange = (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => {
const {state} = event.nativeEvent
const isOpen = state !== 'closed'
this.setState({open: isOpen})
this.props.onStateChange?.(event)
}
private getBackgroundColor = (): ColorValue | undefined => {
const parent = React.Children.toArray(
this.props.children,
)[0] as React.ReactElement
if (parent?.props?.style) {
const parentStyle = StyleSheet.flatten(parent.props.style) as ViewStyle
return parentStyle.backgroundColor ?? 'transparent'
}
return undefined
}
private updateLayout = () => {
this.ref.current?.updateLayout()
}
render() {
const {children, ...rest} = this.props
const topInset = rest.topInset ?? 0
const bottomInset = rest.bottomInset ?? 0
if (!this.state.open) {
return null
}
const backgroundColor = this.getBackgroundColor()
return (
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight - topInset - bottomInset,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={{
flex: 1,
backgroundColor,
paddingTop: topInset,
paddingBottom: bottomInset,
}}>
<View onLayout={this.updateLayout}>{children}</View>
</View>
</NativeView>
)
}
}
@@ -0,0 +1,5 @@
import {BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheetView(_: BottomSheetViewProps) {
throw new Error('BottomSheetView is not available on web')
}
-1
View File
@@ -68,7 +68,6 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-bottom-sheet": "^0.1.1-alpha.11",
"@haileyok/bluesky-video": "0.1.10",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
-5
View File
@@ -4120,11 +4120,6 @@
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
"@haileyok/bluesky-bottom-sheet@^0.1.1-alpha.11":
version "0.1.1-alpha.11"
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-bottom-sheet/-/bluesky-bottom-sheet-0.1.1-alpha.11.tgz#a9c0e1c1903ba066c185481958f4ea9519cd5ba3"
integrity sha512-RtiEpEIXtejBzCtspFO7oEw6n87LnVQuSz+FGGEQZ6ywnRUBy6HZ5bpE3IFI7mmyF2YjAPg2nLzsqR9NXRMlhQ==
"@haileyok/bluesky-video@0.1.10":
version "0.1.10"
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.10.tgz#2756e8c83a78caeb6b120a175578eac1eb6889a9"