From 18f3d8aa6019c03095e7193e08a025ef60a05cb3 Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 5 Mar 2023 15:08:33 +0900 Subject: [PATCH] Init --- .gitignore | 9 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Package.swift | 32 ++++ README.md | 2 + .../SwiftUIHosting/HostingController.swift | 82 +++++++++ .../SwiftUIHosting/SwiftUIHostingView.swift | 167 ++++++++++++++++++ Sources/SwiftUIHosting/UIResponder+.swift | 12 ++ .../SwiftUIHostingTests/MyLibraryTests.swift | 1 + 8 files changed, 313 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.swift create mode 100644 Sources/SwiftUIHosting/HostingController.swift create mode 100644 Sources/SwiftUIHosting/SwiftUIHostingView.swift create mode 100644 Sources/SwiftUIHosting/UIResponder+.swift create mode 100644 Tests/SwiftUIHostingTests/MyLibraryTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..346b27e --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SwiftUIHosting", + platforms: [.iOS(.v13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "SwiftUIHosting", + targets: ["SwiftUIHosting"] + ) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "SwiftUIHosting", + dependencies: [] + ), + .testTarget( + name: "SwiftUIHostingTests", + dependencies: ["SwiftUIHosting"] + ), + ] +) diff --git a/README.md b/README.md index 8b13789..a4342dc 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ +# MyLibrary +A description of this package. diff --git a/Sources/SwiftUIHosting/HostingController.swift b/Sources/SwiftUIHosting/HostingController.swift new file mode 100644 index 0000000..f5c7156 --- /dev/null +++ b/Sources/SwiftUIHosting/HostingController.swift @@ -0,0 +1,82 @@ +import SwiftUI + +@available(iOS 13, *) +final class HostingController: UIHostingController { + + var onViewDidLayoutSubviews: (UIViewController) -> Void = { _ in } + + init(disableSafeArea: Bool, rootView: Content) { + super.init(rootView: rootView) + + // https://www.notion.so/muukii/UIHostingController-safeArea-issue-ec66a560970c4a1cb44f21cc448bc513?pvs=4 +#if USE_SWIZZLING + _ = _once_ + _fixing_safeArea = disableSafeArea +#else + _disableSafeArea = disableSafeArea +#endif + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + onViewDidLayoutSubviews(self) + } +} + +#if USE_SWIZZLING + +private let _once_: Void = { + UIView.replace() +}() + +private var _key: Void? + +extension UIView { + + fileprivate static func replace() { + + method_exchangeImplementations( + class_getInstanceMethod(self, #selector(getter:UIView.safeAreaInsets))!, + class_getInstanceMethod(self, #selector(getter:UIView._hosting_safeAreaInsets))! + ) + + method_exchangeImplementations( + class_getInstanceMethod(self, #selector(getter:UIView.safeAreaLayoutGuide))!, + class_getInstanceMethod(self, #selector(getter:UIView._hosting_safeAreaLayoutGuide))! + ) + + } + + fileprivate var _fixing_safeArea: Bool { + get { + (objc_getAssociatedObject(self, &_key) as? Bool) ?? false + } + set { + objc_setAssociatedObject(self, &_key, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + } + + @objc dynamic var _hosting_safeAreaInsets: UIEdgeInsets { + if _fixing_safeArea { + return .zero + } else { + return self._hosting_safeAreaInsets + } + } + + @objc dynamic var _hosting_safeAreaLayoutGuide: UILayoutGuide? { + if _fixing_safeArea { + return nil + } else { + return self._hosting_safeAreaLayoutGuide + } + } + +} + +#endif diff --git a/Sources/SwiftUIHosting/SwiftUIHostingView.swift b/Sources/SwiftUIHosting/SwiftUIHostingView.swift new file mode 100644 index 0000000..c938d3d --- /dev/null +++ b/Sources/SwiftUIHosting/SwiftUIHostingView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// A view that hosts SwiftUI for UIKit environment. +open class SwiftUIHostingView: UIView { + + public struct Configuration { + + /** + Registers internal hosting controller into the nearest view controller's children. + + Apple developer explains why we need to UIHostinController should be a child of the view controller. + ``` + When using UIHostingController, make sure to always add the view controller together with the view to your app. + + Many SwiftUI features, such as toolbars, keyboard shortcuts, and views that use UIViewControllerRepresentable, require a connection to the view controller hierarchy in UIKit to integrate properly, so never separate a hosting controller's view from the hosting controller itself. + ``` + */ + public var registersAsChildViewController: Bool + + /** + Fixes handling safe area issue + https://www.notion.so/muukii/UIHostingController-safeArea-issue-ec66a560970c4a1cb44f21cc448bc513?pvs=4 + */ + public var disableSafeArea: Bool + + public init( + registersAsChildViewController: Bool = true, + disableSafeArea: Bool = true + ) { + self.registersAsChildViewController = registersAsChildViewController + self.disableSafeArea = disableSafeArea + } + } + + private var hostingController: HostingController + + private let proxy: Proxy = .init() + + public let configuration: Configuration + + public convenience init( + _ name: String = "", + _ file: StaticString = #file, + _ function: StaticString = #function, + _ line: UInt = #line, + configuration: Configuration = .init(), + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + name, + file, + function, + line, + configuration: configuration + ) + setContent(content: content) + } + + // MARK: - Initializers + + public init( + _ name: String = "", + _ file: StaticString = #file, + _ function: StaticString = #function, + _ line: UInt = #line, + configuration: Configuration = .init() + ) { + self.configuration = configuration + + self.hostingController = HostingController( + disableSafeArea: configuration.disableSafeArea, + rootView: RootView(proxy: proxy) + ) + + super.init(frame: .null) + + #if DEBUG + let file = URL(string: file.description)?.deletingPathExtension().lastPathComponent ?? "unknown" + self.accessibilityIdentifier = [ + name, + file, + function.description, + line.description, + ] + .joined(separator: ".") + #endif + + hostingController.view.backgroundColor = .clear + + addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.rightAnchor.constraint(equalTo: rightAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor), + hostingController.view.leftAnchor.constraint(equalTo: leftAnchor), + ]) + + hostingController.onViewDidLayoutSubviews = { controller in + // TODO: Reduces number of calling invalidation, it's going to be happen even it's same value. + controller.view.invalidateIntrinsicContentSize() + } + + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: UIView + + /// Returns calculated size using internal hosting controller + open override func sizeThatFits(_ size: CGSize) -> CGSize { + hostingController.sizeThatFits(in: size) + } + + open override func didMoveToWindow() { + + super.didMoveToWindow() + + if configuration.registersAsChildViewController { + // https://muukii.notion.site/Why-we-need-to-add-UIHostingController-to-view-controller-chain-14de20041c99499d803f5a877c9a1dd1 + + if let _ = window { + if let parentViewController = self.findNearestViewController() { + parentViewController.addChild(hostingController) + hostingController.didMove(toParent: parentViewController) + } else { + assertionFailure() + } + } else { + hostingController.willMove(toParent: nil) + hostingController.removeFromParent() + } + } + } + + // MARK: - + + public final func setContent( + @ViewBuilder content: @escaping () -> Content + ) { + proxy.content = { + return SwiftUI.AnyView( + content() + ) + } + } + +} + +final class Proxy: ObservableObject { + @Published var content: () -> SwiftUI.AnyView? = { nil } + + init() { + } +} + +struct RootView: SwiftUI.View { + @ObservedObject var proxy: Proxy + + var body: some View { + proxy.content() + } +} diff --git a/Sources/SwiftUIHosting/UIResponder+.swift b/Sources/SwiftUIHosting/UIResponder+.swift new file mode 100644 index 0000000..af67dcb --- /dev/null +++ b/Sources/SwiftUIHosting/UIResponder+.swift @@ -0,0 +1,12 @@ +import UIKit + +extension UIResponder { + + func findNearestViewController() -> UIViewController? { + sequence(first: self, next: { $0.next }) + .first { + $0 is UIViewController + } as? UIViewController + } + +} diff --git a/Tests/SwiftUIHostingTests/MyLibraryTests.swift b/Tests/SwiftUIHostingTests/MyLibraryTests.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Tests/SwiftUIHostingTests/MyLibraryTests.swift @@ -0,0 +1 @@ +