How to implement a custom view modifier - ios

To be clear, i'm not asking how to use the ViewModifier protocol to create a struct with the body function that can then be used to modify a view. This question is a little bit different.
I'm trying to create a reusable alternative to the NavigationView struct, which has been mostly successful using #Viewbuilder to enable trailing closure syntax which enables me to use the view i've named 'NavBarView' like this:
NavBarView(foregroundColor: .gray) {
Text("Child view")
}
Which uses the following initializer:
init(foregroundColor: Color, #ViewBuilder content: () -> Content) {
self.foregroundColor = foregroundColor
self.content = content()
}
I can post all the code here for the NavBarView struct if you'd like to see it, but I haven't for brevity.
This code compiles fine and creates the desired effect which looks like this:
However, I'd like to be able to implement the optional ability to add items to the 'navigation bar', similar to how you can call .navigationBarItems(trailing: ) on views inside a navigation view. I'm not sure I could go about implementing this though.
What i've tried so far is creating an optional state property in the NavBarView struct called item, where it's type Item conforms to View as follows:
#State var item: Item?
This item is then placed into an HStack so that when it isn't optional it should be showed next to the "Parent View" text.
I've then written the following function within the NavBarView struct:
func trailingItem(#ViewBuilder _ item: () -> Item) -> some View {
self.item = item()
return self
}
I've then attempted to call the function like this:
NavBarView(foregroundColor: .gray) {
Text("Child view")
}.trailingItem{Text("test test")}
However, I'm not getting any text appearing, and the debug button which i've hooked up to print out what is in the item property prints out nil, so for some reason that function isn't setting the item property as Text("test test").
Am I going about this completely the wrong way? Could someone shed any light on how I might go about achieving the desired behavior?

This is possible approach, the only small correction to your modifier
extension NavBarView {
func trailingItem(#ViewBuilder _ item: #escaping () -> Item) -> some View {
var view = self // make modifiable
view.item = item()
return view
}
}
and so you don't need to have it as #State, just declare it as
fileprivate var item: Item?
Tested with Xcode 11.4

Related

How to dynamically hide the status bar and the home indicator in SwiftUI?

I'm working on a fractal clock app that displays animated fractals based on clock hands in its main view. I want users of my app to be able to enter a fullscreen mode where all unnecessary UI is temporarily hidden and only the animation remains visible. The behavior I'm looking for is similar to Apple's Photos app where one can tap on the currently displayed image so that the navigation bar, the bottom bar, the status bar and the home indicator fade out until the image is tapped again.
Hiding the navigation bar and the status bar was as easy as finding the right view modifiers to pass the hiding condition to. But as far as I know it is currently not possible in SwiftUI to hide the home indicator without bringing in UIKit.
On Stack Overflow I found this solution by Casper Zandbergen for conditionally hiding the home indicator and adopted it for my project.
It works but sadly in comes with an unacceptable side effect: The main view now no longer extends under the status bar and the home indicator which has two implications:
When hiding the status bar with the relevant SwiftUI modifier the space for the main view grows by the height of the hidden status bar interrupting the display of the fractal animation.
In place of the hidden home indicator always remains a black bottom bar preventing the fullscreen presentation of the main view.
I hope somebody with decent UIKit experience can help me with this. Please keep in mind that I'm a beginner in SwiftUI and that I have basically no prior experience with UIKit. Thanks in advance!
import SwiftUI
struct ContentView: View {
#StateObject var settings = Settings()
#State private var showSettings = false
#State private var hideUI = false
var body: some View {
NavigationView {
GeometryReader { proxy in
let radius = 0.5 * min(proxy.size.width, proxy.size.height) - 20
FractalClockView(settings: settings, clockRadius: radius)
}
.ignoresSafeArea(.all)
.toolbar {
Button(
action: { showSettings.toggle() },
label: { Label("Settings", systemImage: "slider.horizontal.3") }
)
.popover(isPresented: $showSettings) { SettingsView(settings: settings) }
}
.navigationBarTitleDisplayMode(.inline)
.onTapGesture {
withAnimation { hideUI.toggle() }
}
.navigationBarHidden(hideUI)
.statusBar(hidden: hideUI)
.prefersHomeIndicatorAutoHidden(hideUI) // Code by Amzd
}
.navigationViewStyle(.stack)
}
}
I was able to solve the problem with the SwiftUI view not extending beyond the safe area insets for the status bar and the home indicator by completely switching to a storyboard based project template and embedding my views through a custom UIHostingController as described in this solution by Casper Zandbergen.
Before I was re-integrating the hosting controller into the SwiftUI view hierarchy by wrapping it with a UIViewRepresentable instance, which must have caused the complications in handling the safe area.
By managing the whole app through the custom UIHostingController subclass it was even easier to get the hiding of the home indicator working. As much as I love SwiftUI I had to realize that, with its current limitations, UIKit was the better option here.
Final code (optimized version of the solution linked above):
ViewController.swift
import SwiftUI
import UIKit
struct HideUIPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue() || value
}
}
extension View {
func userInterfaceHidden(_ value: Bool) -> some View {
preference(key: HideUIPreferenceKey.self, value: value)
}
}
class ViewController: UIHostingController<AnyView> {
init() {
weak var vc: ViewController? = nil
super.init(
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
#objc required dynamic init?(coder: NSCoder) {
weak var vc: ViewController? = nil
super.init(
coder: coder,
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
private var userInterfaceHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersStatusBarHidden: Bool {
userInterfaceHidden
}
override var prefersHomeIndicatorAutoHidden: Bool {
userInterfaceHidden
}
}

How to "unpack" the content of a ForEach

I am building a #resultBuilder in my app. This resultBuilder takes Views and returns them in an array. This is necessary because the view which takes the content (WrappingHStack) needs it as an array. My current source looks like this:
#resultBuilder public struct ViewArrayBuilder {
public static func buildBlock() -> [EmptyView] {
[EmptyView()]
}
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
[AnyView(content)]
}
}
struct WrappingHStack: View {
#usableFromInline var alignment: Alignment = .topLeading
#usableFromInline var spacing: CGFloat = 8.0
#usableFromInline var maxRows: Int = Int.max
#usableFromInline let content: [AnyView]
#State private var height: CGFloat = 0
#usableFromInline init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
content: [AnyView]) {
self.alignment = alignment
self.spacing = spacing
self.maxRows = maxRows
self.content = content
}
#inlinable public init(alignment: Alignment = .topLeading,
spacing: CGFloat = 8.0,
maxRows: Int = Int.max,
#ViewArrayBuilder content: () -> [AnyView]) {
self.init(alignment: alignment, spacing: spacing, maxRows: maxRows, content: content())
}
}
This all works fine if used like so:
WrappingHStack(maxRows: 2) {
Text("1")
Text("2")
...
}
If used with a ForEach in the closure it is recognised as one view and an array with the single ForEach is returned. But I want to get the contents of the ForEach and put them in the array. I was thinking about checking the type of the content and if a ForEach is detected it would be "unpacked".
public static func buildBlock<Content: View>(_ content: Content) -> [AnyView] {
// Doesn’t work because of this error message:
// Protocol 'RandomAccessCollection' as a type cannot conform to the protocol itself
if let forEachContent = content as? ForEach<RandomAccessCollection, Any, Any> {
return content.data.map({ elem in AnyView(content.content(elem)) })
}
return [AnyView(content)]
}
But I can’t seem to find a way to correctly ask for the ForEach type.
How would this be done? Or are there better ways to "unpack" the content of the ForEach?
Update
Why I need that?
I try to create a "wrapping" HStack. That is a view that lays out its children horizontally like a normal HStack. Once the available width is used up it wraps the children and continues on the next line. I base my approach on this article.
So at one point in time I do need the views created with the ForEach construct to lay them out as I want to. If I were able to correctly cast the content parameter of the buildBlock method to the ForEach type I could use the content function of ForEach to create the views. Like shown in the code block above.
I’m also open for other suggestions which accomplish the wrapping stack I need. (The lazy grids Apple provides are not what I want. My child views are of different width and I want them to flow like text would within the WrappingHStack.)
WrappingHStack library does the wrapping and can be used as a forEach:
WrappingHStack(1...30, id:\.self) {
Text("Item: \($0)")
}

Why Does #EnvironmentObject Force Re-initialization of View?

In this example, when I drag across the screen why does LabelViewRepresentable get re-initialized before every "updateUIView" call? If I make the counter a #State property instead of an #EnvironmentObject property, it only initializes once like I would expect.
import SwiftUI
class Counter: ObservableObject {
#Published var count = 0
}
struct CounterView: View {
#EnvironmentObject var counter: Counter
var body: some View {
LabelViewRepresentable(count: $counter.count)
.gesture(DragGesture().onChanged({ _ in
self.counter.count += 1
}))
}
}
struct LabelViewRepresentable: UIViewRepresentable {
#Binding var count: Int
private var view: UILabel
init(count: Binding<Int>) {
print("init")
let label = UILabel()
label.text = "0"
self.view = label
self._count = count
}
func makeUIView(context: UIViewRepresentableContext<LabelViewRepresentable>) -> UILabel {
print("makeUIView")
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<LabelViewRepresentable>) {
print("updateUIView")
view.text = "\(count)"
}
}
When you look at Apple docs about EnvironmentObject you will find this:
A dynamic view property that uses a bindable object supplied by an
ancestor view to invalidate the current view whenever the bindable
object changes.
That means that each time an EnvironmentObject changes all views that are dependent on it get reinitialised and redrawn.
It works slightly different with State, in Apple docs it is described as follows:
A persistent value of a given type, through which a view reads and
monitors the value.
The view cannot get reinitialised when the State changes as the State value would get discarded. The parts that are influenced by State will get redrawn. On the other hand any children of the view that have the State value passed in as a binding will get reinitialised.

SwiftUI ScrollView with content frame update closure

I want to have a ScrollView where you can be aware of the content frame changes as the user scrolls (similar to didScroll delegate in UIKit UIScrollView).
With this, you can then perform layout changes based on the scroll behavior.
I managed to come with a nice solution for this problem by making use of View Preferences as a method to notify layout information upstream in the View Hierarchy.
For a very detail explanation of how View Preferences work, I will suggest reading this 3 articles series on the topic by kontiki
For my solution, I implemented two ViewModifiers: one to make a view report changes on its layout using anchor preferences, and the second to allow a View to handle updates to frames on views on its subtree.
To do this, we first define a Struct to carry the identifiable frame information upstream:
/// Represents the `frame` of an identifiable view as an `Anchor`
struct ViewFrame: Equatable {
/// A given identifier for the View to faciliate processing
/// of frame updates
let viewId : String
/// An `Anchor` representation of the View
let frameAnchor: Anchor<CGRect>
// Conformace to Equatable is required for supporting
// view udpates via `PreferenceKey`
static func == (lhs: ViewFrame, rhs: ViewFrame) -> Bool {
// Since we can currently not compare `Anchor<CGRect>` values
// without a Geometry reader, we return here `false` so that on
// every change on bounds an update is issued.
return false
}
}
and we define a Struct conforming to PreferenceKey protocol to hold the view tree preference changes:
/// A `PreferenceKey` to provide View frame updates in a View tree
struct FramePreferenceKey: PreferenceKey {
typealias Value = [ViewFrame] // The list of view frame changes in a View tree.
static var defaultValue: [ViewFrame] = []
/// When traversing the view tree, Swift UI will use this function to collect all view frame changes.
static func reduce(value: inout [ViewFrame], nextValue: () -> [ViewFrame]) {
value.append(contentsOf: nextValue())
}
}
Now we can define the ViewModifiers I mentioned:
Make a view report changes on its layout:
This just adds a transformAnchorPreference modifier to the View with a handler that simply constructs a ViewFrame instance with current frame Anchor value and appends it to the current value of the FramePreferenceKey:
/// Adds an Anchor preference to notify of frame changes
struct ProvideFrameChanges: ViewModifier {
var viewId : String
func body(content: Content) -> some View {
content
.transformAnchorPreference(key: FramePreferenceKey.self, value: .bounds) {
$0.append(ViewFrame(viewId: self.viewId, frameAnchor: $1))
}
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func provideFrameChanges(viewId : String) -> some View {
ModifiedContent(content: self, modifier: ProvideFrameChanges(viewId: viewId))
}
}
Provide an update handler to a view for frame changes on its subtree:
This adds a onPreferenceChange modifier to the View, where the list of frame Anchors changes are transformed into frames(CGRect) on the view's coordinate space and reported as a dictionary of frame updates keyed by the view ids:
typealias ViewTreeFrameChanges = [String : CGRect]
/// Provides a block to handle internal View tree frame changes
/// for views using the `ProvideFrameChanges` in own coordinate space.
struct HandleViewTreeFrameChanges: ViewModifier {
/// The handler to process Frame changes on this views subtree.
/// `ViewTreeFrameChanges` is a dictionary where keys are string view ids
/// and values are the updated view frame (`CGRect`)
var handler : (ViewTreeFrameChanges)->Void
func body(content: Content) -> some View {
GeometryReader { contentGeometry in
content
.onPreferenceChange(FramePreferenceKey.self) {
self._updateViewTreeLayoutChanges($0, in: contentGeometry)
}
}
}
private func _updateViewTreeLayoutChanges(_ changes : [ViewFrame], in geometry : GeometryProxy) {
let pairs = changes.map({ ($0.viewId, geometry[$0.frameAnchor]) })
handler(Dictionary(uniqueKeysWithValues: pairs))
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func handleViewTreeFrameChanges(_ handler : #escaping (ViewTreeFrameChanges)->Void) -> some View {
ModifiedContent(content: self, modifier: HandleViewTreeFrameChanges(handler: handler))
}
}
LET'S USE IT:
I will illustrate the usage with an example:
Here I will get notifications of a Header View frame changes inside a ScrollView. Since this Header View is on the top of the ScrollView content, the reported frame changes on the frame origin are equivalent to the contentOffset changes of the ScrollView
enum TestEnum : String, CaseIterable, Identifiable {
case one, two, three, four, five, six, seven, eight, nine, ten
var id: String {
rawValue
}
}
struct TestView: View {
private let _listHeaderViewId = "testView_ListHeader"
var body: some View {
ScrollView {
// Header View
Text("This is some Header")
.provideFrameChanges(viewId: self._listHeaderViewId)
// List of test values
ForEach(TestEnum.allCases) {
Text($0.rawValue)
.padding(60)
}
}
.handleViewTreeFrameChanges {
self._updateViewTreeLayoutChanges($0)
}
}
private func _updateViewTreeLayoutChanges(_ changes : ViewTreeFrameChanges) {
print(changes)
}
}
There is an elegant solution to this problem, Soroush Khanlou already posted a Gist of it so I won't copy-paste it. You can find it here and yeah...Shame that it isn't a part of the framework yet!

Make an invisible SwiftUI to UIKit to SwiftUI bridge

I have found out some features are terribly lacking on the SwiftUI side, especially on the interactions. So I am trying to add up an in-betweener "invisible" UIViewController sandwiched between two UIViews.
TL;DR: See point 1 below.
For example, to make sure a view can receive a UIDrop interaction, or to add an UIDrag interaction, or any other interaction that can actually be influenced by other softwares (such as an iPad drag from another software to mine).
So I created a little piece of fun code that actually simply wraps around a View "Content" and gets called when the view is actually instantiated. I wanted to forego the Controller part but it doesn't seem possible on the iOS part of the equation, only macOS. Boo. Anyways, I know these features will probably eventually be added to SwiftUI, so the obvious goal of that bridge is to be as transparent as possible, so I can remove it whenever the proper way gets added to SwiftUI.
It mostly works, believe it or not! But there is a nitpick and a bug, and maybe someone actually did something similar and could actually help me figure it out. And maybe my code will help someone else as a good starting point.
(SwiftUI 11.0 beta 5)
(Bug and main question) It seems the frame size isn't propagated. So the parent of my class must actually hard-code .frame(width: something, height: something) or else my view will not be of a proper size... Which makes sense, as I don't actually propagate these values nor retrieve them from the Content. I tried hugging the size, but the frame size is not sent. Where and how should I get this value transmitted across the board. The goal of my code is to make this bridge as lean, efficient and invisible as possible to SwiftUI, developer and the end user.
(Nitpick) I found the viewWillAppear, viewDidLoad and other load-time operations are not actually called. viewWillAppear, for example, isn't called at view-time. If I navigate deeper and go back, then viewWillAppear will be called, but not the first time it's shown. So I resorted in creating a temporary "initialized" variable. What am I doing wrong (if any)?
Maybe I'm also forgetting to transmit other data across the classes and structs. For example, the UIViewController actually makes the object's background white. It could be transparent, but for me it's of no importance (I guess you can add self.view.backgroundColor = .clear). If you think of improvements, I'd be very happy to know about it!
import SwiftUI
/// Creates a SwiftUI to UIKit to SwiftUI bridge, where a lambda is called on viewWillAppear
struct UIKitBridgeView<Content>: View where Content : View {
var content: () -> Content
var onViewWillAppear: (UIView)->Void
init(onViewWillAppear: #escaping (UIView)->Void, _ content: #escaping () -> Content) {
self.content = content
self.onViewWillAppear = onViewWillAppear
}
class Coordinator: NSObject {
}
/// This is the class in UIKit that can host a SwiftUI "Content" type.
class HostingController: UIHostingController<Content> {
var onViewWillAppear: ((UIView)->Void)?
var initialized: Bool = false
init(_ content: () -> Content) {
super.init(rootView: content())
self.onViewWillAppear = nil
}
init(_ content: () -> Content, _ onViewWillAppear: #escaping (UIView)->Void) {
super.init(rootView: content())
self.onViewWillAppear = onViewWillAppear
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.onViewWillAppear = nil
}
func update(context: UIViewControllerRepresentableContext<ViewControllerRepresentable>) {
if !initialized {
initialized = true
if let onViewWillAppear = self.onViewWillAppear {
onViewWillAppear(self.view)
}
// self.view.setContentHuggingPriority(.defaultHigh, for: .vertical) doesn't seem to do anything...
// self.view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
}
}
}
/// This is the struct in SwiftUI that can host a UIKit's ViewController
struct ViewControllerRepresentable : UIViewControllerRepresentable {
let content: () -> Content
var onViewWillAppear: ((UIView)->Void)?
init(_ content: #escaping () -> Content) {
self.content = content
self.onViewWillAppear = nil
}
init(_ content: #escaping () -> Content, _ onViewWillAppear: #escaping (UIView)->Void) {
self.content = content
self.onViewWillAppear = onViewWillAppear
}
func makeUIViewController(context: Context) -> HostingController {
self.onViewWillAppear != nil ?
HostingController(self.content, self.onViewWillAppear!) :
HostingController(self.content)
}
func updateUIViewController(_ hostingController: HostingController, context: Context) {
hostingController.update(context: context)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
}
var body: some View {
ViewControllerRepresentable(self.content, self.onViewWillAppear)
}
}
For a point 1 incomplete example :
UIKitBridgeView(onViewWillAppear: { (view) in
if let delegate = self.appState.dragInteractionDelegate {
view.addInteraction(self.createDraggable(delegate))
}
}) {
Spacer() // Insert what you want here
}.frame(width: 50, height: 50)
And the .frame(width, height) really annoys me!

Resources