How do I make a preferenceKey accept a View in SwiftUI? - swiftui

I'm trying to build a custom NavigationView, and I'm struggling with how to implement a custom ".navigationBarItems(leading: /* insert views /, trailing: / insert Views */)". I assume I have to use a preferenceKey, but I don't know how to make it accept views.
My top menu looks something like this:
import SwiftUI
struct TopMenu<Left: View, Right: View>: View {
let leading: Left
let trailing: Right
init(#ViewBuilder left: #escaping () -> Left, #ViewBuilder right: #escaping () -> Right) {
self.leading = left()
self.trailing = right()
}
var body: some View {
VStack(spacing: 0) {
HStack {
leading
Spacer()
trailing
}.frame(height: 30, alignment: .center)
Spacer()
}
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
}
}
struct TopMenu_Previews: PreviewProvider {
static var previews: some View {
TopMenu(left: { }, right: { })
}
}
And this is my attempt at creating a preferenceKey to update it with, where I've obviously missed something very basic:
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue:View
static func reduce(value: inout View, nextValue: () -> View) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue:View
static func reduce(value: inout View, nextValue: () -> View) {
value = nextValue()
}
}
extension View {
func topMenuItems(leading: View, trailing: View) -> some View {
self.preference(key: TopMenuItemsLeading.self, value: leading)
self.preference(key: TopMenuItemsTrailing.self, value: trailing)
}
}

Ok, so there was some great partial answers in here, but none that actually achieved what I asked, which was to pass a view up the view-hierarchy using a preferenceKey. Essentially what the .navigationBarItems method is doing, but with my own custom view.
I found a solution however, so here goes (and apologies if I missed any obvious short-cuts. This IS my first time using preferenceKeys for anything):
import SwiftUI
struct TopMenu: View {
#State private var show:Bool = false
var body: some View {
VStack {
TopMenuView {
Button("Change", action: { show.toggle() })
Text("Hello world!")
.topMenuItems(leading: Image(systemName: show ? "xmark.circle" : "pencil"))
.topMenuItems(trailing: Image(systemName: show ? "pencil" : "xmark.circle"))
}
}
}
}
struct TopMenu_Previews: PreviewProvider {
static var previews: some View {
TopMenu()
}
}
/*
To emulate .navigationBarItems(leading: View, trailing: View), I need four things:
1) EquatableViewContainer - Because preferenceKeys need to be equatable to be able to update when a change occurred
2) PreferenceKeys - That use the EquatableViewContainer for both leading and trailing views
3) ViewExtenstions - That allow us to set the preferenceKeys individually or one at a time
4) TopMenu view - That we can set somewhere higher in the view hierarchy.
*/
// First, create an EquatableViewContainer we can use as preferenceKey data
struct EquatableViewContainer: Equatable {
let id = UUID().uuidString
let view:AnyView
static func == (lhs: EquatableViewContainer, rhs: EquatableViewContainer) -> Bool {
return lhs.id == rhs.id
}
}
// Second, define preferenceKeys that uses the Equatable view container
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
value = nextValue()
}
}
// Third, create view-extensions for each of the ways to modify the TopMenu
extension View {
// Change only leading view
func topMenuItems<LView: View>(leading: LView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
}
// Change only trailing view
func topMenuItems<RView: View>(trailing: RView) -> some View {
self
.preference(key: TopMenuItemsTrailing.self, value: EquatableViewContainer(view: AnyView(trailing)))
}
// Change both leading and trailing views
func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
}
}
// Fourth, create the view for the TopMenu
struct TopMenuView<Content: View>: View {
// Content to put into the menu
let content: Content
#State private var leading:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
#State private var trailing:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content()
}
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack {
leading.view
Spacer()
trailing.view
}
Text("TopMenu").fontWeight(.black)
}
.padding(EdgeInsets(top: 0, leading: 2, bottom: 5, trailing: 2))
.background(Color.gray.edgesIgnoringSafeArea(.top))
content
Spacer()
}
.onPreferenceChange(TopMenuItemsLeading.self, perform: { value in
leading = value
})
.onPreferenceChange(TopMenuItemsTrailing.self, perform: { value in
trailing = value
})
}
}
`````

The possible approach is to use AnyView, like
struct TopMenuItemsLeading: PreferenceKey {
static var defaultValue: AnyView = AnyView(EmptyView())
static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
value = nextValue()
}
}
struct TopMenuItemsTrailing: PreferenceKey {
static var defaultValue: AnyView = AnyView(EmptyView())
static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
value = nextValue()
}
}
extension View {
func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
self
.preference(key: TopMenuItemsLeading.self, value: AnyView(leading))
.preference(key: TopMenuItemsTrailing.self, value: AnyView(trailing))
}
}

You could declare the initialiser of TopView to take Views like this:
struct TopMenu<Left: View, Right: View>: View {
let leading: Left
let trailing: Right
init(left: Left,
right: Right) {
self.leading = left
self.trailing = right
}
//etc.
And then declare the modifier similarly to how navigationBarItems modifiers are defined:
extension View {
func topMenuItems<L, T>(leading: L, trailing: T) -> some View where L : View, T : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: leading, right: trailing)
self
}
}
func topMenuItems<L>(leading: L) -> some View where L : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: leading, right: EmptyView())
self
}
}
func topMenuItems<T>(trailing: T) -> some View where T : View {
VStack(alignment: .center, spacing: 0) {
TopMenu(left: EmptyView(), right: trailing)
self
}
}
}

Related

Is this possible to return which griditem in LazyVGrid using position in SwiftUI?

I got a LazyVGrid, and I would like to know is this possible to return which griditem by given a gesture.location. Thanks.
To store the location of each item in a LazyVGrid, do this using PreferenceKey:
First define an object to store the frame for each view in the grid:
struct ModelFrame: Hashable {
let id: String
let frame: CGRect
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
then create a PreferenceKey as follows:
struct ModelFrameKey: PreferenceKey {
static var defaultValue: Set<ModelFrame> = []
static func reduce(value: inout Set<ModelFrame>, nextValue: () -> Set<ModelFrame>) {
value = value.union(nextValue())
}
}
Then for each view in the grid, add a clear background wrapped in a GeometryReader with .preference modifier to store the frame for the view.
struct Model: Identifiable {
var id: String { name }
let name: String
}
struct ContentView: View {
let models = ["abc", "def", "ghi", "jkl", "mno", "pqr", "stu"].map(Model.init)
#State private var modelFrames: Set<ModelFrame> = []
#State private var overModel: Model?
var body: some View {
VStack {
Text("Over: \(overModel?.name ?? "nothing")")
.font(.title)
LazyVGrid(columns: [GridItem(), GridItem()]) {
ForEach(models) { model in
Text(model.name)
.padding()
.background(overModel?.id == model.id ? .red : .yellow, in: RoundedRectangle(cornerRadius: 8))
.background {
GeometryReader { proxy in
Color.clear.preference(key: ModelFrameKey.self, value: [ModelFrame(id: model.id, frame: proxy.frame(in: .named("Grid")))])
}
}
}
}
.coordinateSpace(name: "Grid")
.onPreferenceChange(ModelFrameKey.self) { frames in
self.modelFrames = frames
}
.gesture(
DragGesture()
.onChanged { value in
overModel = model(containing: value.location)
}
)
}
}
func model(containing point: CGPoint) -> Model? {
guard let id = modelFrames.first(where: { $0.frame.contains(point) })?.id else {
return nil
}
return models.first(where: { $0.id == id })
}
Now you have your frames, you can use CGRect.contains(CGPoint) in your drag gesture to see which view contains the current location.

How can you Drag to refresh a Grid View (LazyVGrid) in Swiftui?

How do you drag to refresh a grid view in swiftui? I know you can do it with List view with refreshable modifier in iOS 15, but how can you do it with a LazyVGrid? How would you do it in either List or Grid view pre iOS 15? I pretty new at swiftui. I attached a gif showing what Im trying to achieve.
Drag to Refresh
Here is the code LazyVStack:
import SwiftUI
struct PullToRefreshSwiftUI: View {
#Binding private var needRefresh: Bool
private let coordinateSpaceName: String
private let onRefresh: () -> Void
init(needRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: #escaping () -> Void) {
self._needRefresh = needRefresh
self.coordinateSpaceName = coordinateSpaceName
self.onRefresh = onRefresh
}
var body: some View {
HStack(alignment: .center) {
if needRefresh {
VStack {
Spacer()
ProgressView()
Spacer()
}
.frame(height: 100)
}
}
.background(GeometryReader {
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
value: $0.frame(in: .named(coordinateSpaceName)).origin.y)
})
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
guard !needRefresh else { return }
if abs(offset) > 50 {
needRefresh = true
onRefresh()
}
}
}
}
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
And here is typical usage:
struct ContentView: View {
#State private var refresh: Bool = false
#State private var itemList: [Int] = {
var array = [Int]()
(0..<40).forEach { value in
array.append(value)
}
return array
}()
var body: some View {
ScrollView {
PullToRefreshSwiftUI(needRefresh: $refresh,
coordinateSpaceName: "pullToRefresh") {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { refresh = false }
}
}
LazyVStack {
ForEach(itemList, id: \.self) { item in
HStack {
Spacer()
Text("\(item)")
Spacer()
}
}
}
}
.coordinateSpace(name: "pullToRefresh")
}
}
This can be easily adapted for LazyVGrid, just replace LazyVStack.
EDIT:
Here is more refined variant:
struct PullToRefresh: View {
private enum Constants {
static let refreshTriggerOffset = CGFloat(-140)
}
#Binding private var needsRefresh: Bool
private let coordinateSpaceName: String
private let onRefresh: () -> Void
init(needsRefresh: Binding<Bool>, coordinateSpaceName: String, onRefresh: #escaping () -> Void) {
self._needsRefresh = needsRefresh
self.coordinateSpaceName = coordinateSpaceName
self.onRefresh = onRefresh
}
var body: some View {
HStack(alignment: .center) {
if needsRefresh {
VStack {
Spacer()
ProgressView()
Spacer()
}
.frame(height: 60)
}
}
.background(GeometryReader {
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self,
value: -$0.frame(in: .named(coordinateSpaceName)).origin.y)
})
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
guard !needsRefresh, offset < Constants.refreshTriggerOffset else { return }
withAnimation { needsRefresh = true }
onRefresh()
}
}
}
private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
private enum Constants {
static let coordinateSpaceName = "PullToRefreshScrollView"
}
struct PullToRefreshScrollView<Content: View>: View {
#Binding private var needsRefresh: Bool
private let onRefresh: () -> Void
private let content: () -> Content
init(needsRefresh: Binding<Bool>,
onRefresh: #escaping () -> Void,
#ViewBuilder content: #escaping () -> Content) {
self._needsRefresh = needsRefresh
self.onRefresh = onRefresh
self.content = content
}
var body: some View {
ScrollView {
PullToRefresh(needsRefresh: $needsRefresh,
coordinateSpaceName: Constants.coordinateSpaceName,
onRefresh: onRefresh)
content()
}
.coordinateSpace(name: Constants.coordinateSpaceName)
}
}

How to make custom .sheet viewmodifier

I am trying to recreate the native .sheet() view modifier in SwiftUI. When I look at the definition, I get below function, but I'm not sure where to go from there.
The .sheet somehow passes a view WITH bindings to a distant parent at the top of the view-tree, but I can't see how that is done. If you use PreferenceKey with an AnyView, you can't have bindings.
My usecase is that I want to define a sheet in a subview, but I want to activate it at a distant parent-view to avoid it interfering with other code.
func showSheet<Content>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, #ViewBuilder content: #escaping () -> Content) -> some View where Content : View {
// What do I put here?
}
So, I ended up doing my own sheet in SwiftUI using a preferenceKey for passing the view up the view-tree, and an environmentObject for passing the binding for showing/hiding the sheet back down again.
It's a bit long-winded, but here's the gist of it:
struct HomeOverlays<Content: View>: View {
#Binding var showSheet:Bool
#State private var sheet:EquatableViewContainer = EquatableViewContainer(id: "original", view: AnyView(Text("No view")))
#State private var animatedSheet:Bool = false
#State private var dragPercentage:Double = 0 /// 1 = fully visible, 0 = fully hidden
// Content
let content: Content
init(_ showSheet: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._showSheet = showSheet
self.content = content()
}
var body: some View {
GeometryReader { geometry in
ZStack {
content
.blur(radius: 5 * dragPercentage)
.opacity(1 - dragPercentage * 0.5)
.disabled(showSheet)
.scaleEffect(1 - 0.1 * dragPercentage)
.frame(width: geometry.size.width, height: geometry.size.height)
if animatedSheet {
sheet.view
.background(Color.greyB.opacity(0.5).edgesIgnoringSafeArea(.bottom))
.cornerRadius(5)
.transition(.move(edge: .bottom).combined(with: .opacity))
.dragToSnap(snapPercentage: 0.3, dragPercentage: $dragPercentage) { showSheet = false } /// Custom modifier for measuring how far the view is dragged down. If more than 30% it snaps showSheet to false, and otherwise it snaps it back up again
.edgesIgnoringSafeArea(.bottom)
}
}
.onPreferenceChange(HomeOverlaySheet.self, perform: { value in self.sheet = value } )
.onChange(of: showSheet) { show in sheetUpdate(show) }
}
}
func sheetUpdate(_ show:Bool) {
withAnimation(.easeOut(duration: 0.2)) {
self.animatedSheet = show
if show { dragPercentage = 1 } else { dragPercentage = 0 }
}
// Delay onDismiss action if removing sheet, so animation can complete
if show == false {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
sheet.action()
}
}
}
}
struct HomeOverlays_Previews: PreviewProvider {
static var previews: some View {
HomeOverlays(.constant(false)) {
Text("Home overlays")
}
}
}
// MARK: Preference key for passing view up the tree
struct HomeOverlaySheet: PreferenceKey {
static var defaultValue: EquatableViewContainer = EquatableViewContainer(id: "default", view: AnyView(EmptyView()) )
static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
if value != nextValue() && nextValue().id != "default" {
value = nextValue()
}
}
}
// MARK: View extension for defining view somewhere in view tree
extension View {
// Change only leading view
func homeSheet<SheetView: View>(onDismiss action: #escaping () -> Void, #ViewBuilder sheet: #escaping () -> SheetView) -> some View {
let sheet = sheet()
return
self
.preference(key: HomeOverlaySheet.self, value: EquatableViewContainer(view: AnyView( sheet ), action: action ))
}
}

How to loop over viewbuilder content subviews in SwiftUI

So I’m trying to create a view that takes viewBuilder content, loops over the views of the content and add dividers between each view and the other
struct BoxWithDividerView<Content: View>: View {
let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack(alignment: .center, spacing: 0) {
// here
}
.background(Color.black)
.cornerRadius(14)
}
}
so where I wrote “here” I want to loop over the views of the content, if that makes sense. I’ll write a code that doesn’t work but that explains what I’m trying to achieve:
ForEach(content.subviews) { view in
view
Divider()
}
How to do that?
I just answered on another similar question, link here. Any improvements to this will be made for the linked answer, so check there first.
GitHub link of this (but more advanced) in a Swift Package here
However, here is the answer with the same TupleView extension, but different view code.
Usage:
struct ContentView: View {
var body: some View {
BoxWithDividerView {
Text("Something 1")
Text("Something 2")
Text("Something 3")
Image(systemName: "circle") // Different view types work!
}
}
}
Your BoxWithDividerView:
struct BoxWithDividerView: View {
let content: [AnyView]
init<Views>(#ViewBuilder content: #escaping () -> TupleView<Views>) {
self.content = content().getViews
}
var body: some View {
VStack(alignment: .center, spacing: 0) {
ForEach(content.indices, id: \.self) { index in
if index != 0 {
Divider()
}
content[index]
}
}
// .background(Color.black)
.cornerRadius(14)
}
}
And finally the main thing, the TupleView extension:
extension TupleView {
var getViews: [AnyView] {
makeArray(from: value)
}
private struct GenericView {
let body: Any
var anyView: AnyView? {
AnyView(_fromValue: body)
}
}
private func makeArray<Tuple>(from tuple: Tuple) -> [AnyView] {
func convert(child: Mirror.Child) -> AnyView? {
withUnsafeBytes(of: child.value) { ptr -> AnyView? in
let binded = ptr.bindMemory(to: GenericView.self)
return binded.first?.anyView
}
}
let tupleMirror = Mirror(reflecting: tuple)
return tupleMirror.children.compactMap(convert)
}
}
Result:
So I ended up doing this
#_functionBuilder
struct UIViewFunctionBuilder {
static func buildBlock<V: View>(_ view: V) -> some View {
return view
}
static func buildBlock<A: View, B: View>(
_ viewA: A,
_ viewB: B
) -> some View {
return TupleView((viewA, Divider(), viewB))
}
}
Then I used my function builder like this
struct BoxWithDividerView<Content: View>: View {
let content: () -> Content
init(#UIViewFunctionBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack(spacing: 0.0) {
content()
}
.background(Color(UIColor.AdUp.carbonGrey))
.cornerRadius(14)
}
}
But the problem is this only works for up to 2 expression views. I’m gonna post a separate question for how to be able to pass it an array

Measure the rendered size of a SwiftUI view?

Is there a way to measure the computed size of a view after SwiftUI runs its view rendering phase? For example, given the following view:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
}
}
With the view selected, the computed size is displayed In the preview canvas in the bottom left corner. Does anyone know of a way to get access to that size in code?
Printing out values is good, but being able to use them inside the parent view (or elsewhere) is better. So I took one more step to elaborate it.
struct GeometryGetter: View {
#Binding var rect: CGRect
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
DispatchQueue.main.async { // avoids warning: 'Modifying state during view update.' Doesn't look very reliable, but works.
self.rect = g.frame(in: .global)
}
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
#State private var rect1: CGRect = CGRect()
var body: some View {
HStack {
// make to texts equal width, for example
// this is not a good way to achieve this, just for demo
Text("Long text").background(Color.blue).background(GeometryGetter(rect: $rect1))
// You can then use rect in other places of your view:
Text("text").frame(width: rect1.width, height: rect1.height).background(Color.green)
Text("text").background(Color.yellow)
}
}
}
You could add an "overlay" using a GeometryReader to see the values. But in practice it would probably be better to use a "background" modifier and handle the sizing value discretely
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(
GeometryReader { proxy in
Text("\(proxy.size.width) x \(proxy.size.height)")
}
)
}
}
Here is the ugly way I came up with to achieve this:
struct GeometryPrintingView: View {
var body: some View {
GeometryReader { geometry in
return self.makeViewAndPrint(geometry: geometry)
}
}
func makeViewAndPrint(geometry: GeometryProxy) -> Text {
print(geometry.size)
return Text("")
}
}
And updated Foo version:
struct Foo : View {
var body: some View {
Text("Hello World!")
.font(.title)
.foregroundColor(.white)
.padding()
.background(Color.red)
.overlay(GeometryPrintingView())
}
}
To anyone who wants to obtain a size out of Jack's solution and store it in some property for further use:
.overlay(
GeometryReader { proxy in
Text(String())
.onAppear() {
// Property, eg
// #State private var viewSizeProperty = CGSize.zero
viewSizeProperty = proxy.size
}
.opacity(.zero)
}
)
This is a bit dirty obviously, but why not if it works.
Try to use PreferenceKey like this.
struct HeightPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct WidthPreferenceKey : PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct SizePreferenceKey : PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
func readWidth() -> some View {
background(GeometryReader {
Color.clear.preference(key: WidthPreferenceKey.self, value: $0.size.width)
})
}
func readHeight() -> some View {
background(GeometryReader {
Color.clear.preference(key: HeightPreferenceKey.self, value: $0.size.height)
})
}
func onWidthChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(WidthPreferenceKey.self) { width in
action(width)
}
}
func onHeightChange(perform action: #escaping (CGFloat) -> Void) -> some View {
onPreferenceChange(HeightPreferenceKey.self) { height in
action(height)
}
}
func readSize() -> some View {
background(GeometryReader {
Color.clear.preference(key: SizePreferenceKey.self, value: $0.size)
})
}
func onSizeChange(perform action: #escaping (CGSize) -> Void) -> some View {
onPreferenceChange(SizePreferenceKey.self) { size in
action(size)
}
}
}
struct MyView: View {
#State private var height: CGFloat = 0
var body: some View {
...
.readHeight()
.onHeightChange {
height = $0
}
}
}
As others pointed out, GeometryReader and a custom PreferenceKey is the best way forward for now. I've implemented a helper drop-in library which does pretty much that: https://github.com/srgtuszy/measure-size-swiftui