├── .gitattributes ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources └── HostingPassthrough └── HostingPassthrough.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | # spm 6 | .swiftpm/ 7 | 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 12 | *.xcscmblueprint 13 | *.xccheckout 14 | 15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 16 | build/ 17 | DerivedData/ 18 | *.moved-aside 19 | *.pbxuser 20 | !default.pbxuser 21 | *.mode1v3 22 | !default.mode1v3 23 | *.mode2v3 24 | !default.mode2v3 25 | *.perspectivev3 26 | !default.perspectivev3 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | 31 | ## App packaging 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # 56 | # We recommend against adding the Pods directory to your .gitignore. However 57 | # you should judge for yourself, the pros and cons are mentioned at: 58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 59 | # 60 | # Pods/ 61 | # 62 | # Add this line if you want to avoid checking in source code from the Xcode workspace 63 | # *.xcworkspace 64 | 65 | # Carthage 66 | # 67 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 68 | # Carthage/Checkouts 69 | 70 | Carthage/Build/ 71 | 72 | # Accio dependency management 73 | Dependencies/ 74 | .accio/ 75 | 76 | # fastlane 77 | # 78 | # It is recommended to not store the screenshots in the git repo. 79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 80 | # For more information about the recommended setup visit: 81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 82 | 83 | fastlane/report.xml 84 | fastlane/Preview.html 85 | fastlane/screenshots/**/*.png 86 | fastlane/test_output 87 | 88 | # Code Injection 89 | # 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "HostingPassthrough", 8 | products: [ 9 | .library( 10 | name: "HostingPassthrough", 11 | targets: ["HostingPassthrough"] 12 | ), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "HostingPassthrough", 17 | dependencies: [] 18 | ), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HostingPassthrough 2 | 3 | UIHostingController blocks all touches behind it. You basically have to choose SwiftUI, or UIKit? If you want a SwiftUI overlay over a UIKit view for example, you can't have that. 4 | 5 | ### But SwiftUI and UIKit views deserve to live in harmony. 6 | 7 | HostingPassthrough allows you to fix this by inheriting `HostingParentController` instead of `UIViewController` in the places where you will be adding SwiftUI views through UIHostingController. 8 | 9 | image 10 | 11 | * If you don't want to force your UIHostingControllers to have clear backgrounds, set `makeBackgroundsClear = false` in `viewDidLoad()`. 12 | * If you want to forward touches on the base view of the HostingParentController to another view, set `forwardBaseTouchesTo` to another UIView you want to handle your touches. 13 | * You can also now inherit or initalise a `HostingParentView` instead of `UIView`, in the case that you aren't adding the `UIHostingController` to a parent view controller. While you should always add the `UIHostingController` to a parent view controller if possible to correctly manage view lifecycle, there may be instances where you are for example adding SwiftUI components to a reusable custom UIView in which case using `HostingParentView` would be acceptable. 14 | * If SwiftUI `ScrollView` is messing up your tap targets, you can set `ignoreTouchesOnSwiftUIScrollView` to `true` so any touches that reach the bottom of a SwiftUI `ScrollView` (*not* the content), are passed to whatever is under it. 15 | 16 | Some cool logic will then be applied overriding the `hitTest` method, which ignores any touches in parts of a `UIHostingController` that don't contain a SwiftUI view and pass it to whatever is underneath instead. 17 | 18 | # 19 | 20 | **TODO:** 21 | * [ ] Fix SwiftUI `.contentShape()` not working. 22 | 23 | # 24 | 25 | All these views are tappable, even behind the 3 UIHostingController's! 26 | 27 | Screenshot 2023-01-10 at 11 17 47 pm 28 | 29 | 30 | 31 | https://user-images.githubusercontent.com/40876121/211558172-e02bb348-51f7-4a60-ae39-2dd3d29de06b.mov 32 | 33 | -------------------------------------------------------------------------------- /Sources/HostingPassthrough/HostingPassthrough.swift: -------------------------------------------------------------------------------- 1 | // created by christian privitelli on 10/01/2023 2 | 3 | import SwiftUI 4 | 5 | open class HostingParentController: UIViewController { 6 | public var makeBackgroundsClear = true 7 | 8 | /// If the touches land on the base view of the HostingParentController, they will be forwarded to this view if it is not nil. 9 | public var forwardBaseTouchesTo: UIView? 10 | 11 | /// If the touches land on the bottom of a SwiftUI scroll container (*not* the content), pass through these touches to the UIKit layer underneath. 12 | public var ignoreTouchesOnSwiftUIScrollView = false 13 | 14 | override public func loadView() { 15 | let capturer = HostingParentView() 16 | view = capturer 17 | } 18 | 19 | public override func viewWillLayoutSubviews() { 20 | super.viewWillLayoutSubviews() 21 | 22 | let capturer = view as! HostingParentView 23 | capturer.makeBackgroundsClear = makeBackgroundsClear 24 | capturer.forwardBaseTouchesTo = forwardBaseTouchesTo 25 | capturer.ignoreTouchesOnSwiftUIScrollView = ignoreTouchesOnSwiftUIScrollView 26 | } 27 | } 28 | 29 | /// Use HostingParentView instead of UIView in places where you aren't adding a UIHostingController to a view controller. Otherwise use HostingParentController instead. 30 | open class HostingParentView: UIView { 31 | private var hostingViews: [UIView] = [] 32 | public var forwardBaseTouchesTo: UIView? 33 | public var makeBackgroundsClear = true 34 | public var ignoreTouchesOnSwiftUIScrollView = false 35 | 36 | override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 37 | guard let hitTest = super.hitTest(point, with: event) else { return nil } 38 | 39 | return checkBehind(view: hitTest, point: point, event: event) 40 | } 41 | 42 | // what you need to know for this logic > 43 | // 44 | // in the view hierachy a UIHostingController has a private _UIHostingView that contains all the SwiftUI content. 45 | // when we do a hit test and it returns a _UIHostingView, this means we have hit the background of the hosting view, and not actually a SwiftUI view. 46 | // 47 | // when a hit test lands on a SwiftUI view, the class is something like _TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView, so not the actual _UIHostingView that contains it. 48 | // 49 | // therefore we then should continue to check behind the _UIHostingView until we reach something underneath that isn't another _UIHostingView view. 50 | // this could either be a SwiftUI view with a weird class name like above OR a UIKit view. 51 | 52 | private func checkBehind(view: UIView, point: CGPoint, event: UIEvent?) -> UIView? { 53 | // if the hittest lands on a hosting view, and it has user interaction enabled, we check behind it. 54 | // otherwise just return the view directly (this is almost definitely a UIKit view). 55 | if let view = hostingViews.first(where: { $0 == view }), view.isUserInteractionEnabled { 56 | // in order to check behind the _UIHostingView that captures all of the touches, we can tell it to stop accepting touches, then perform another hittest in the same location to see what's underneath it. 57 | view.isUserInteractionEnabled = false 58 | guard let hitBehind = super.hitTest(point, with: event) else { return nil } 59 | 60 | // for some reason this causes a crash if we don't set it back on the main thread 61 | DispatchQueue.main.async { 62 | view.isUserInteractionEnabled = true 63 | } 64 | 65 | // if the view behind is another _UIHostingView, we check behind THAT, and the process continues until we land on something that isn't a _UIHostingView. 66 | if hostingViews.contains(hitBehind) { 67 | return checkBehind(view: hitBehind, point: point, event: event) 68 | } else { 69 | // yay we found something behind 70 | // if it is the base view, then forward it to whatever we have set here 71 | if let forwardBaseTouchesTo = forwardBaseTouchesTo, hitBehind == self { 72 | // some special logic to check if we are forwarding to the superview. 73 | // if we are, then we want to make sure not to return itself again otherwise we'd be creating an endless loop. 74 | if forwardBaseTouchesTo == superview { 75 | let hit = super.hitTest(point, with: event) 76 | return hit == self ? nil : hit 77 | } else { 78 | return forwardBaseTouchesTo.hitTest(point, with: event) 79 | } 80 | } else { 81 | return view 82 | } 83 | } 84 | } else { 85 | if let forwardBaseTouchesTo = forwardBaseTouchesTo, view == self { 86 | if forwardBaseTouchesTo == superview { 87 | let hit = super.hitTest(point, with: event) 88 | return hit == self ? nil : hit 89 | } else { 90 | return forwardBaseTouchesTo.hitTest(point, with: event) 91 | } 92 | 93 | // if we are hitting the back of a scroll view, it's possible you might want to pass this touch through to the uikit layer underneath. scrolling is still possible when touching items, just not the bottom of the scroll view. 94 | } else if String(describing: view).contains("HostingScrollView"), view.isUserInteractionEnabled, ignoreTouchesOnSwiftUIScrollView { 95 | view.isUserInteractionEnabled = false 96 | 97 | guard let hitBehindScrollView = super.hitTest(point, with: event) else { return nil } 98 | 99 | DispatchQueue.main.async { 100 | view.isUserInteractionEnabled = true 101 | } 102 | 103 | return checkBehind(view: hitBehindScrollView, point: point, event: event) 104 | } else { 105 | return view 106 | } 107 | } 108 | } 109 | 110 | override public func layoutSubviews() { 111 | super.layoutSubviews() 112 | 113 | hostingViews = subviews.filter { 114 | // so it isn't exactly called _UIHostingView, and it's a private class, so we just check against the description of it. 115 | // reliable as of iOS 16.3 when this was made 116 | String(describing: $0.self).contains("_UIHostingView") 117 | } 118 | 119 | guard makeBackgroundsClear else { return } 120 | 121 | hostingViews.forEach { 122 | $0.backgroundColor = .clear 123 | } 124 | } 125 | } 126 | --------------------------------------------------------------------------------