SwiftUI Casting TupleView to an array of AnyView - swift

Code
I have the following code:
struct CustomTabView: View where Content: View {
let children: [AnyView]
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
let m = Mirror(reflecting: content())
if let value = m.descendant("value") {
let tupleMirror = Mirror(reflecting: value)
let tupleElements = tupleMirror.children.map({ AnyView($0.value) }) // ERROR
self.children = tupleElements
} else {
self.children = [AnyView]()
}
}
var body: some View {
ForEach(self.children) { child in
child...
}
}
}
Problem
I'm trying to convert the TupleView into an array of AnyView but I'm receiving the error
Protocol type 'Any' cannot conform to 'View' because only concrete types can conform to protocols
Possible solution
One way I can solve this is to pass in type erased views into CustomTabView like so:
CustomTabView {
AnyView(Text("A"))
AnyView(Text("B"))
AnyView(Rectangle())
}
Ideally
but I'd like to be able to do the following just like the native TabView
CustomTabView {
Text("A")
Text("B")
Rectangle()
}
So how would I go about converting the TupleView into an array of AnyView?

Here's how I went about creating a custom tab view with SwiftUI:
struct CustomTabView<Content>: View where Content: View {
#State private var currentIndex: Int = 0
#EnvironmentObject private var model: Model
let content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
var body: some View {
GeometryReader { geometry in
return ZStack {
// pages
// onAppear on all pages are called only on initial load
self.pagesInHStack(screenGeometry: geometry)
}
.overlayPreferenceValue(CustomTabItemPreferenceKey.self) { preferences in
// tab bar
return self.createTabBar(screenGeometry: geometry, tabItems: preferences.map {TabItem(tag: $0.tag, tab: $0.item)})
}
}
}
func getTabBarHeight(screenGeometry: GeometryProxy) -> CGFloat {
// https://medium.com/#hacknicity/ipad-navigation-bar-and-toolbar-height-changes-in-ios-12-91c5766809f4
// ipad 50
// iphone && portrait 49
// iphone && portrait && bottom safety 83
// iphone && landscape 32
// iphone && landscape && bottom safety 53
if UIDevice.current.userInterfaceIdiom == .pad {
return 50 + screenGeometry.safeAreaInsets.bottom
} else if UIDevice.current.userInterfaceIdiom == .phone {
if !model.landscape {
return 49 + screenGeometry.safeAreaInsets.bottom
} else {
return 32 + screenGeometry.safeAreaInsets.bottom
}
}
return 50
}
func pagesInHStack(screenGeometry: GeometryProxy) -> some View {
let tabBarHeight = getTabBarHeight(screenGeometry: screenGeometry)
let heightCut = tabBarHeight - screenGeometry.safeAreaInsets.bottom
let spacing: CGFloat = 100 // so pages don't overlap (in case of leading and trailing safetyInset), arbitrary
return HStack(spacing: spacing) {
self.content()
// reduced height, so items don't appear under tha tab bar
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height - heightCut)
// move up to cover the reduced height
// 0.1 for iPhone X's nav bar color to extend to status bar
.offset(y: -heightCut/2 - 0.1)
}
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * screenGeometry.size.width + -CGFloat(self.currentIndex) * spacing)
}
func createTabBar(screenGeometry: GeometryProxy, tabItems: [TabItem]) -> some View {
let height = getTabBarHeight(screenGeometry: screenGeometry)
return VStack {
Spacer()
HStack(spacing: screenGeometry.size.width / (CGFloat(tabItems.count + 1) + 0.5)) {
Spacer()
ForEach(0..<tabItems.count, id: \.self) { i in
Group {
Button(action: {
self.currentIndex = i
}) {
tabItems[i].tab
}.foregroundColor(self.currentIndex == i ? .blue : .gray)
}
}
Spacer()
}
// move up from bottom safety inset
.padding(.bottom, screenGeometry.safeAreaInsets.bottom > 0 ? screenGeometry.safeAreaInsets.bottom - 5 : 0 )
.frame(width: screenGeometry.size.width, height: height)
.background(
self.getTabBarBackground(screenGeometry: screenGeometry)
)
}
// move down to cover bottom of new iphones and ipads
.offset(y: screenGeometry.safeAreaInsets.bottom)
}
func getTabBarBackground(screenGeometry: GeometryProxy) -> some View {
return GeometryReader { tabBarGeometry in
self.getBackgrounRectangle(tabBarGeometry: tabBarGeometry)
}
}
func getBackgrounRectangle(tabBarGeometry: GeometryProxy) -> some View {
return VStack {
Rectangle()
.fill(Color.white)
.opacity(0.8)
// border top
// https://www.reddit.com/r/SwiftUI/comments/dehx9t/how_to_add_border_only_to_bottom/
.padding(.top, 0.2)
.background(Color.gray)
.edgesIgnoringSafeArea([.leading, .trailing])
}
}
}
Here's the preference and view extensions:
// MARK: - Tab Item Preference
struct CustomTabItemPreferenceData: Equatable {
var tag: Int
let item: AnyView
let stringDescribing: String // to let preference know when the tab item is changed
var badgeNumber: Int // to let preference know when the badgeNumber is changed
static func == (lhs: CustomTabItemPreferenceData, rhs: CustomTabItemPreferenceData) -> Bool {
lhs.tag == rhs.tag && lhs.stringDescribing == rhs.stringDescribing && lhs.badgeNumber == rhs.badgeNumber
}
}
struct CustomTabItemPreferenceKey: PreferenceKey {
typealias Value = [CustomTabItemPreferenceData]
static var defaultValue: [CustomTabItemPreferenceData] = []
static func reduce(value: inout [CustomTabItemPreferenceData], nextValue: () -> [CustomTabItemPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
// TabItem
extension View {
func customTabItem<Content>(#ViewBuilder content: #escaping () -> Content) -> some View where Content: View {
self.preference(key: CustomTabItemPreferenceKey.self, value: [
CustomTabItemPreferenceData(tag: 0, item: AnyView(content()), stringDescribing: String(describing: content()), badgeNumber: 0)
])
}
}
// Tag
extension View {
func customTag(_ tag: Int, badgeNumber: Int = 0) -> some View {
self.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) -> Void in
guard value.count > 0 else { return }
value[0].tag = tag
value[0].badgeNumber = badgeNumber
}
.tag(tag)
}
}
and here's the usage:
struct MainTabsView: View {
var body: some View {
// TabView
CustomTabView {
A()
.customTabItem { ... }
.customTag(0, badgeNumber: 1)
B()
.customTabItem { ... }
.customTag(2)
C()
.customTabItem { ... }
.customTag(3)
}
}
}
I hope that's useful to y'all out there, let me know if you know a better way!

I created IterableViewBuilder for this purpose
struct ContentView: View {
...
init<C: IterableView>(#IterableViewBuilder content: () -> C) {
let count = content().count
content().iterate(with: Visitor())
}
}
struct Visitor: IterableViewVisitor {
func visit<V>(_ value: V) where V : View {
print("value")
}
}
...
ContentView {
Text("0")
Text("1")
}

Related

View not updating in modal hierarchy

I'm trying to create a modal popup system working similarly to .fullScreenCover, looking something like this:
My requirements are:
Have custom transition and presentation style (therefore I can't use .fullScreenCover)
Be able to present modal from child components
Here's a functional code snippet that satisfies those two conditions, you can run it:
struct Screen: View {
#StateObject private var model = Model()
var body: some View {
Navigation {
VStack {
Text("model.number: \(model.number)").opacity(0.5)
ChildComponent(number: $model.number)
Spacer()
}
.padding(.vertical, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.purple.opacity(0.4))
}
}
}
struct ChildComponent: View {
#EnvironmentObject var navigator: Navigator
#Binding var number: Int
#State private var isFullScreenPresented = false
var body: some View {
VStack(spacing: 20) {
Text("\(number)").bold()
Button("Change (custom)", action: presentCustom).foregroundColor(.black)
Button("Change (full screen)", action: presentFullScreen).foregroundColor(.black)
}
.padding(30)
.background(Color.black.opacity(0.1))
.modalBottom(id: "childModal") {
NumberModalView(number: $number)
}
.fullScreenCover(isPresented: $isFullScreenPresented) {
NumberModalView(number: $number).environment(\.dismissModal, { isFullScreenPresented = false })
}
}
func presentCustom() {
navigator.presentModalBottom(id: "childModal")
}
func presentFullScreen() {
isFullScreenPresented = true
}
}
struct ModalView<Content:View>: View {
#Environment(\.dismissModal) var dismissCallback
#ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: 30) {
Button("Dismiss", action: { dismissCallback() }).foregroundColor(.black)
content()
}
.padding(30)
.frame(maxWidth: .infinity)
.background(Color.purple.opacity(0.8))
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
struct NumberModalView: View {
#Binding var number: Int
var body: some View {
ModalView {
HStack(spacing: 20) {
Button(action: { number -= 1 }) { Image(systemName: "minus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
Text("\(number)").bold()
Button(action: { number += 1 }) { Image(systemName: "plus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
}
}
}
}
// MARK: - Navigation
struct Navigation<Content:View>: View {
#ViewBuilder var content: () -> Content
#StateObject private var navigator = Navigator()
#State private var modalPresentations: [String:ModalData] = [:]
var body: some View {
ZStack {
content()
if let modalID = navigator.currentModalBottom, let modal = modalPresentations[modalID] {
modal.content().environment(\.dismissModal, navigator.dismissModalBottom)
}
}
.environmentObject(navigator)
.onPreferenceChange(ModalPresentationKey.self) { modalPresentations in
self.modalPresentations = modalPresentations
}
}
}
// MARK: - Model
class Model: ObservableObject {
#Published var number: Int = 0
}
struct ModalData: Hashable {
var id: String
var content: () -> AnyView
static func == (lhs: ModalData, rhs: ModalData) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
class Navigator: ObservableObject {
#Published var currentModalBottom: String?
func presentModalBottom(id: String) {
currentModalBottom = id
}
func dismissModalBottom() {
currentModalBottom = nil
}
}
// MARK: - Dismiss (Environment key)
private struct ModalDismissKey: EnvironmentKey {
static let defaultValue: () -> Void = {}
}
extension EnvironmentValues {
var dismissModal: () -> Void {
get { self[ModalDismissKey.self] }
set { self[ModalDismissKey.self] = newValue }
}
}
// MARK: - Present (Preference key)
struct ModalPresentationKey: PreferenceKey {
static var defaultValue: [String:ModalData] = [:]
static func reduce(value: inout [String:ModalData], nextValue: () -> [String:ModalData]) {
for (k,v) in nextValue() { value[k] = v }
}
}
extension View {
func modalBottom<V:View>(id: String, #ViewBuilder content: #escaping () -> V) -> some View {
preference(key: ModalPresentationKey.self, value: [
id: ModalData(id: id, content: { AnyView(content()) })
])
}
}
// MARK: - Preview
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Screen()
}
}
}
Now the problem: while the parent view value gets updated (values at the top), the modal view value is not updated (text box between the stepper at the bottom). If you try with the default full screen, you'll see that it works normally.
I'm guessing it's a problem with data flow and the fact that the modal is not a child of the component.
Since I've already spent weeks on this problem, here are some surprising things I found:
If you replace the #StateObject model with a simple #State var of type Int in Screen, it works (?!). In my case, I have a complex model which I can't replace with simple state variables.
If you add a dependency to the navigator in NumberModalView, by adding #Environment(\.dismissModal) var dismissCallback, it works. This seems crazy to me, I don't see what role the navigator is playing in the modal data flow.
How to make the modal view react to model changes while keeping my requirements above?
I already talked about this problem here and here, but at the time I was bridging with UIKit and I thought the problem came from that, but it doesn't.

Want to "deal" cards in Swift: how to stop cards from changing sizes as more cards are dealt

I need help with this animation. Basically, I want to deal 12 cards when I tap on the "deck." However, I want to deal it so that the sizes of each card is already set; I don't want the card size to change as more cards come onto screen. Put differently, to put 12 cards on the screen, at the end, we have 3 rows of 4 cards. But the Swift system tries to put 3 cards per row, and then moves to 4 cards per row. This changing of card size looks very weird, so I was hoping to put in some code that makes it so the card size doesn't change.
ContentView
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel: GameViewModel
#Namespace private var dealingNamespace
var body: some View {
VStack {
newGameButton
gameBody
HStack {
deckBody
discardedDeckBody
}
shuffleButton
}
}
var newGameButton: some View {
Button(action: { viewModel.newGame() }) { Text("New Game")
.font(.caption) }
}
var gameBody: some View {
AspectVGrid(items: viewModel.playingCards, aspectRatio: 2/3) { card in
CardView(viewModel: viewModel, card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.padding(4)
// .transition(AnyTransition.asymmetric(insertion: .identity, removal: .scale))
.onTapGesture {
withAnimation {
viewModel.choose(card)
}
}
.foregroundColor(viewModel.borderColor(for: card))
}
.padding(.horizontal)
}
private func dealAnimation(for cardNumber: Int, _ totalCardsToDeal: Int) -> Animation {
let delay = Double(cardNumber) * (CardConstants.totalDealDuration / Double(totalCardsToDeal))
return Animation.easeInOut(duration: CardConstants.dealDuration).delay(delay)
}
var deckBody: some View {
ZStack {
ForEach(viewModel.undealtCards) { card in
CardView(viewModel: viewModel, card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
// .transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
}
.frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
.foregroundColor(CardConstants.color)
.onTapGesture {
for cardNumber in Array(0..<viewModel.numberOfCardsToDeal()) {
withAnimation(dealAnimation(for: cardNumber, viewModel.numberOfCardsToDeal())) {
viewModel.deal()
}
}
}
}
var discardedDeckBody: some View {
ZStack {
ForEach(viewModel.discardedCards()) { card in
CardView(viewModel: viewModel, card: card)
}
}
.frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
.foregroundColor(CardConstants.color)
}
private struct CardConstants {
static let color = Color.red
static let aspectRatio: CGFloat = 2/3
static let dealDuration: Double = 0.5
static let totalDealDuration: Double = 2
static let undealtHeight: CGFloat = 90
static let undealtWidth = undealtHeight * aspectRatio
}
var shuffleButton: some View {
Button("Shuffle") {
withAnimation {
viewModel.shuffle()
}
}
}
}
struct CardView: View {
#ObservedObject var viewModel: GameViewModel
let card: GameModel.Card
var body: some View {
GeometryReader { geometry in
ZStack {
let shape = RoundedRectangle(cornerRadius: DrawingConstants.cornerRadius)
shape.fill().foregroundColor(.white)
shape.stroke(lineWidth: DrawingConstants.lineWidth)
viewModel.symbol(for: card)
.padding(.all)
shape.foregroundColor(viewModel.cover(for: card))
}
}
}
}
struct DrawingConstants {
static let cornerRadius: CGFloat = 10
static let lineWidth: CGFloat = 3
static let fontScale: CGFloat = 0.7
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let game = GameViewModel()
ContentView(viewModel: game)
.previewInterfaceOrientation(.portrait)
}
}
AspectVGrid
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
let computedWidth: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
let width: CGFloat = computedWidth > 65 ? computedWidth : 65
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
}
Spacer(minLength: 0)
}
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
var columnCount = 1
var rowCount = itemCount
repeat {
let itemWidth = size.width / CGFloat(columnCount)
let itemHeight = itemWidth / itemAspectRatio
if CGFloat(rowCount) * itemHeight < size.height {
break
}
columnCount += 1
rowCount = (itemCount + (columnCount - 1)) / columnCount
} while columnCount < itemCount
if columnCount > itemCount {
columnCount = itemCount
}
return floor(size.width / CGFloat(columnCount))
}
}
Model
import Foundation
import SwiftUI
struct GameModel {
private(set) var cards: Array<Card>
var undealtCards: Array<Card>
var discardedCards: Array<Card> = []
private(set) var playingCards: Array<Card>
private var selectedCards: Array<Card> = []
private static var colors = [ContentColor.red, ContentColor.green, ContentColor.purple]
private static var shapes = [ContentShape.squiggly, ContentShape.circle, ContentShape.diamond]
private static var fillings = [ContentFilling.empty, ContentFilling.full, ContentFilling.striped]
private static var numbers = [1, 2, 3]
func numberOfUndealtCards() -> Int {
return undealtCards.count
}
mutating func deal() {
playingCards.append(undealtCards[0])
undealtCards.remove(at: 0)
}
func numberOfCardsToDeal() -> Int {
if numberOfUndealtCards() == 81 {
return 12
} else {
return 3
}
}
mutating func resetCards() {
if !playingCards.first(where: { $0 == selectedCards.first })!.isMatched! {
selectedCards.forEach { card in
let unmatchedIndex = playingCards.firstIndex(of: card)!
playingCards[unmatchedIndex].isMatched = nil
}
selectedCards = []
}
}
mutating func shuffle() {
playingCards = playingCards.shuffled()
}
mutating func choose(_ card: Card) {
if let chosenIndex = playingCards.firstIndex(where: { $0.id == card.id }) {
if selectedCards.count < 3 || selectedCards.count == 3 && !selectedCards.contains(playingCards[chosenIndex]) {
if selectedCards.count == 3 { resetCards() }
if !playingCards[chosenIndex].isSelected {
playingCards[chosenIndex].isSelected = true
selectedCards.append(playingCards[chosenIndex])
if selectedCards.count == 4 {
let chosenCard = playingCards[chosenIndex]
for card in selectedCards {
if card != chosenCard {
discardedCards.append(card)
let matchedIndex = playingCards.firstIndex(of: card)!
playingCards.remove(at: matchedIndex)
}
}
selectedCards = [chosenCard]
}
if selectedCards.count == 3 {
if formSet(by: selectedCards) {
selectedCards.forEach { card in
let index = playingCards.firstIndex(of: card)!
playingCards[index].isMatched = true
playingCards[index].isSelected = false
}
} else {
selectedCards.forEach { card in
let index = playingCards.firstIndex(of: card)!
playingCards[index].isMatched = false
playingCards[index].isSelected = false
}
}
}
}
else {
playingCards[chosenIndex].isSelected = false
selectedCards.removeAll(where: { $0 == playingCards[chosenIndex] })
}
}
}
}
private func formSet(by cards: [Card]) -> Bool {
return feature(\Card.color, isValidFor: cards) && feature(\Card.shape, isValidFor: cards) && feature(\Card.filling, isValidFor: cards) && feature(\Card.number, isValidFor: cards)
}
private func feature<F: Hashable>(_ keyPath: KeyPath<Card, F>, isValidFor cards: [Card]) -> Bool {
let count = cards.reduce(into: Set<F>()) { $0.insert($1[keyPath: keyPath]) }.count
return count == 1 || count == 3
}
init() {
cards = []
var counter = 0
for id1 in GameModel.colors.indices {
for id2 in GameModel.shapes.indices {
for id3 in GameModel.fillings.indices {
for id4 in GameModel.numbers.indices {
cards.append(Card(color: GameModel.colors[id1], shape: GameModel.shapes[id2], filling: GameModel.fillings[id3], number: GameModel.numbers[id4], id: counter))
counter += 1
}
}
}
}
playingCards = []
undealtCards = cards
}
enum ContentColor {
case red
case green
case purple
}
enum ContentShape {
case squiggly
case circle
case diamond
}
enum ContentFilling {
case empty
case full
case striped
}
struct Card: Identifiable, Equatable {
var color: ContentColor
var shape: ContentShape
var filling: ContentFilling
var number: Int
var id: Int
var isSelected = false
var isUndealt = false
var isMatched: Bool?
static func == (lhs: Card, rhs: Card) -> Bool {
return lhs.id == rhs.id
}
}
}
Code is untestable and unclear due to missed many components, but the issue is because use use withAnimation, like
.onTapGesture {
withAnimation {
viewModel.choose(card)
}
}
which animates everything animatable.
There are next directions to fix observed behavior
disable animations everywhere they are unneeded, for example animating of frames
.frame(width: CardConstants.undealtWidth, height: CardConstants.undealtHeight)
.animation(nil, value: viewModel.undealtCards) // << like this !!
.foregroundColor(CardConstants.color)
remove all explicit generic withAnimation and use animation(_, value) specifically in those places which are going to be animatable, like
VStack {
newGameButton
gameBody
HStack {
deckBody
discardedDeckBody
}
shuffleButton
}
.animation(.default, value: viewModel.playingCards) // << here !!
probably combine 1-2

How can I create text in SwiftUI with NavigationLink (only some words from text)

I'm working on SwiftUI application for iOS.
I want to format text in this way, where blue words should be NavigationLinks. How the text should look:
I know that it is possible to implement UIKit into SwiftUI code. However, I don't understand how I can use UIKit in this way with normally working NavigationLinks.
Using this SO question as a base this is a solution. Left justified but an approach.
import SwiftUI
struct DynamicLinkTextView: View {
let text: String = "I want text like this, with NavigationLinks to another View. However, this doesn't work"
let wordsWLinks: [String] = ["this", "View"]
#State var selection: String?
var textArray: [String]{
text.components(separatedBy: " ")
}
var body: some View {
NavigationView{
VStack{
MultilineHStack(textArray){ text in
VStack{
if wordsWLinks.contains(text.removePunctiation()){
NavigationLink(text + " ", destination: Text("link = \(text)"), tag: text as String, selection: $selection)
}else{
Text(text + " ").fixedSize()
}
}
}
}
}
}
}
//https://stackoverflow.com/questions/57510093/swiftui-how-to-have-hstack-wrap-children-along-multiple-lines-like-a-collectio
public struct MultilineHStack: View {
struct SizePreferenceKey: PreferenceKey {
typealias Value = [CGSize]
static var defaultValue: Value = []
static func reduce(value: inout Value, nextValue: () -> Value) {
value.append(contentsOf: nextValue())
}
}
private let items: [AnyView]
#State private var sizes: [CGSize] = []
public init<Data: RandomAccessCollection, Content: View>(_ data: Data, #ViewBuilder content: (Data.Element) -> Content) {
self.items = data.map { AnyView(content($0)) }
}
public var body: some View {
GeometryReader {geometry in
ZStack(alignment: .topLeading) {
ForEach(self.items.indices) { index in
self.items[index].background(self.backgroundView()).offset(self.getOffset(at: index, geometry: geometry))
}
}
}.onPreferenceChange(SizePreferenceKey.self) {
self.sizes = $0
}
}
private func getOffset(at index: Int, geometry: GeometryProxy) -> CGSize {
guard index < sizes.endIndex else {return .zero}
let frame = sizes[index]
var (x,y,maxHeight) = sizes[..<index].reduce((CGFloat.zero,CGFloat.zero,CGFloat.zero)) {
var (x,y,maxHeight) = $0
x += $1.width
if x > geometry.size.width {
x = $1.width
y += maxHeight
maxHeight = 0
}
maxHeight = max(maxHeight, $1.height)
return (x,y,maxHeight)
}
if x + frame.width > geometry.size.width {
x = 0
y += maxHeight
}
return .init(width: x, height: y)
}
private func backgroundView() -> some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(
key: SizePreferenceKey.self,
value: [geometry.frame(in: CoordinateSpace.global).size]
)
}
}
}
struct DynamicLinkTextView_Previews: PreviewProvider {
static var previews: some View {
DynamicLinkTextView()
}
}
extension String{
func removePunctiation() -> String {
self.trimmingCharacters(in: CharacterSet(charactersIn: ",."))
}
}

A view extension that runs conditional code based on its GeometryReader results

I’ve created a View extension to read its offset (inspired by https://fivestars.blog/swiftui/swiftui-share-layout-information.html):
func readOffset(in coordinateSpace: String? = nil, onChange: #escaping (CGFloat) -> Void) -> some View {
background(
GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: coordinateSpace == nil ? .global : .named(coordinateSpace)).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self, perform: onChange)
}
I’m also using Federico’s readSize function:
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
The two work together to help me determine whether a child view within a scrollview is on/off-screen:
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
#State var size: CGSize = .zero
var body: some View {
ScrollView(.vertical) {
ForEach(items, id: \.self) { item in
GeometryReader { geo in
VStack {
Text("Item \(item)")
}.id(item)
.readOffset(in: "scroll") { newOffset in
if !isOffscreen(when: newOffset, in: size.height) {
visibleItems.insert(item)
}
else {
visibleItems.remove(item)
}
}
}.frame(height: 300)
}
}.coordinateSpace(name: "scroll")
}
.readSize { newSize in
self.size = newSize
}
}
This is the isOffscreen function that checks for visibility:
func isOffscreen(when offset: CGFloat, in height: CGFloat) -> Bool {
if offset <= 0 && offset + height >= 0 {
return false
}
return true
}
Everything works fine. However, I’d like to optimise the code further into a single extension that checks for visibility based on the offset and size.height inputted, and also receives parameters for what to do if visible and when not i.e. move readOffset’s closure to be logic that co-exists with the extension code.
I’ve no idea whether this is feasible but thought it’s worth an ask.
You just need to create a View or ViewModifier that demands some Bindings. Note, the code below is just an example of some of the patterns you can use (e.g., an optional binding, escaping content closure), but in the form of a Stack style wrap rather than a ViewModifier (which based on the blog you know how to setup).
struct ScrollableVStack<Content: View>: View {
let content: Content
#Binding var useScrollView: Bool
#Binding var scroller: ScrollViewProxy?
#State private var staticGeo = ViewGeometry()
#State private var scrollContainerGeo = ViewGeometry()
let topFade: CGFloat
let bottomFade: CGFloat
init(_ useScrollView: Binding<Bool>,
topFade: CGFloat = 0.09,
bottomFade: CGFloat = 0.09,
_ scroller: Binding<ScrollViewProxy?> = .constant(nil),
#ViewBuilder _ content: #escaping () -> Content ) {
_useScrollView = useScrollView
_scroller = scroller
self.content = content()
self.topFade = topFade
self.bottomFade = bottomFade
}
var body: some View {
if useScrollView { scrollView }
else { VStack { staticContent } }
}
var scrollView: some View {
ScrollViewReader { scroller in
ScrollView(.vertical, showsIndicators: false) {
staticContent
.onAppear { self.scroller = scroller }
}
.geometry($scrollContainerGeo)
.fadeInOut(topFade: staticGeo.size.height * topFade,
bottomFade: staticGeo.size.height * bottomFade)
}
.onChange(of: staticGeo.size.height) { newStaticHeight in
useScrollView = newStaticHeight > scrollContainerGeo.size.height * 0.85
}
}
var staticContent: some View {
content
.geometry($staticGeo)
.padding(.top, staticGeo.size.height * topFade * 1.25)
.padding(.bottom, staticGeo.size.height * bottomFade)
}
}

Error: Value of type 'some View' has no member 'stroke'

I am following Stanfords' CS193p Developing Apps for iOS online course.
I am using Xcode 11.5. (I didn't update because that's the version the course instructor (Paul Heagarty) is using.)
I'm trying to do the Assignment 3 (Set Game). Currently (to avoid duplicating code) I am trying to replace this fragment:
VStack {
ForEach(0..<numberOfShapes) { index in
if self.card.shape == .diamond {
ZStack {
Diamond().fill()
.opacity(self.opacity)
Diamond().stroke(lineWidth: self.shapeEdgeLineWidth)
}
}
if self.card.shape == .squiggle {
ZStack {
Rectangle().fill()
Rectangle().stroke(lineWidth: self.shapeEdgeLineWidth)
}
}
if self.card.shape == .oval {
ZStack {
Ellipse().fill()
Ellipse().stroke(lineWidth: self.shapeEdgeLineWidth)
}
}
}
}
With this fragment:
VStack {
ForEach(0..<numberOfShapes) { index in
ZStack {
shape(self.card.shape).opacity(self.opacity)
shape(self.card.shape).stroke(lineWidth: 2.5) // ERROR here: Value of type 'some View' has no member 'stroke'
}
}
}
And this #ViewBuilder function:
#ViewBuilder
func shape(_ shape: SetGameModel.Card.Shape) -> some View {
if shape == .diamond {
Diamond()
} else if shape == .squiggle {
Rectangle()
} else {
Ellipse()
}
}
And here is full View code:
import SwiftUI
struct SetGameView: View {
#ObservedObject var viewModel: SetGameViewModel
var body: some View {
Grid(viewModel.cards) { card in
CardView(card: card).onTapGesture {
self.viewModel.choose(card: card)
}
.padding(5)
}
.padding()
.foregroundColor(Color.orange)
}
}
struct CardView: View {
var card: SetGameModel.Card
var numberOfShapes: Int {
switch card.numberOfShapes {
case .one:
return 1
case .two:
return 2
case .three:
return 3
}
}
var opacity: Double {
switch card.shading {
case .open:
return 0.0
case .solid: // filled
return 1.0
case .striped: // you can use a semi-transparent color to represent the “striped” shading.
return 0.33
}
}
var color: Color {
switch card.color {
case .green:
return Color.green
case .purple:
return Color.purple
case .red:
return Color.red
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius).fill(Color.white)
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(lineWidth: card.isChosen ? chosenCardEdgeLineWidth : normalCardEdgeLineWidth)
.foregroundColor(card.isChosen ? Color.red : Color.orange)
VStack {
ForEach(0..<numberOfShapes) { index in
ZStack {
shape(self.card.shape).opacity(self.opacity)
shape(self.card.shape).stroke(lineWidth: 2.5) // ERROR here: Value of type 'some View' has no member 'stroke'
}
}
}
.foregroundColor(self.color)
.padding()
}
.aspectRatio(cardAspectRatio, contentMode: .fit)
}
// MARK: - Drawing Constants
let cornerRadius: CGFloat = 10.0
let chosenCardEdgeLineWidth: CGFloat = 6
let normalCardEdgeLineWidth: CGFloat = 3
let shapeEdgeLineWidth: CGFloat = 2.5
let cardAspectRatio: CGSize = CGSize(width: 2, height: 3) // 2/3 aspectRatio
}
#ViewBuilder
func shape(_ shape: SetGameModel.Card.Shape) -> some View {
if shape == .diamond {
Diamond()
} else if shape == .squiggle {
Rectangle()
} else {
Ellipse()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
SetGameView(viewModel: SetGameViewModel())
}
}
And I have this Error:
Value of type 'some View' has no member 'stroke'
I can't figure this out, what's wrong? How can I fix it?
Please try to help me fix it in some way, that I will understand as a beginner 🙏
Diamond() is my custom Shape by the way (like Rectangle()).
If you also need ViewModel, Model or some other files to help me fix it, let me know :-)
stroke is defined on Shape, not View and you are returning Shapes, not just Views. You need to change the return type of shape to some Shape.
Sadly #ViewBuilder needs the return type to be some View, not some Shape, so you need to remove the #ViewBuilder attribute and make sure your function returns the same Shape from each branch. To achieve this, you can implement a type-erased Shape, called AnyShape similar to AnyView for View and return the type erased version of each Shape.
struct AnyShape: Shape {
init<S: Shape>(_ wrapped: S) {
_path = { rect in
let path = wrapped.path(in: rect)
return path
}
}
func path(in rect: CGRect) -> Path {
return _path(rect)
}
private let _path: (CGRect) -> Path
}
func shape(_ shape: SetGameModel.Card.Shape) -> some Shape {
if shape == .diamond {
return AnyShape(Diamond())
} else if shape == .squiggle {
return AnyShape(Rectangle())
} else {
return AnyShape(Ellipse())
}
}