Choppy scrolling with ScrollView when updating View while scrolling SwiftUI - ios

I am using my own ScrollView which has scroll start and end callbacks, so that I can perform some actions based on them, like hiding/showing a banner.
Here's my code for ScrollView
struct TrackableScrollView<Content>: View where Content: View {
private let onScrollingStarted: () -> Void
private let onScrollingFinished: () -> Void
#State var scrollViewHelper = ScrollViewHelper()
let content: Content
public init(#ViewBuilder content: () -> Content,
onScrollingStarted: #escaping () -> Void = {},
onScrollingFinished: #escaping () -> Void = {}) {
self.content = content()
self.onScrollingStarted = onScrollingStarted
self.onScrollingFinished = onScrollingFinished
}
public var body: some View {
GeometryReader { outsideProxy in
ScrollView(.vertical, showsIndicators: true) {
ZStack(alignment: .top) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
}
self.content
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollViewHelper.currentOffset = value[0]
}
.simultaneousGesture(
DragGesture().onChanged { _ in
onScrollingStarted()
}
)
.onReceive(scrollViewHelper.$offsetAtScrollEnd) { _ in
onScrollingFinished()
}
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
class ScrollViewHelper: ObservableObject {
#Published var currentOffset: CGFloat = 0
#Published var offsetAtScrollEnd: CGFloat = 0
private var cancellable: AnyCancellable?
init() {
cancellable = AnyCancellable($currentOffset
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.dropFirst()
.assign(to: \.offsetAtScrollEnd, on: self))
}
}
And here's my code to show some content
struct ContentView: View {
#State var messageBannerVisisbility: Bool = false
var body: some View {
VStack {
TrackableScrollView {
VStack(alignment: .center, spacing: 0) {
ForEach(0...100, id: \.self) { i in
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.green)
.overlay(Text("\(i)"))
.padding()
}
}
} onScrollingStarted: {
hideMessageBanner()
} onScrollingFinished: {
showMessageBanner()
}
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationBarHidden(true)
.onAppear {
showMessageBanner()
}
}
}
I am toggling messageBannerVisisbility with animation inside showMessageBanner() function.
If I keep below code, scrolling is not smooth. Could it be because I am showing/hiding the banner with animation on scroll callbacks? I guess I can just update the banner, instead of the whole View, but I am not sure how can I achieve that!
if messageBannerVisisbility {
Rectangle()
.frame(height: 100)
.foregroundColor(.red)
.overlay(Text("Random bottom view"))
.transition(.move(edge: .bottom).combined(with: .opacity))
}
What could I do to improve scrolling experience? My app does support iOS 13, but I am also fine with implementing 2 different solutions, one for iOS 13 and other one for iOS 14 and above, if that makes life a little easier!

Related

How to fit horizontal ScrollView to content's height?

I have a horizontal scroll view with a LazyHStack. How do I size the scroll view to automatically fit the content inside?
By default, ScrollView takes up all the vertical space possible.
struct ContentView: View {
var numbers = 1...100
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(numbers, id: \.self) {
Text("\($0)")
.font(.largeTitle)
}
}
}
}
}
This code can be MUCH better
I have show it as BASE for your final solution
But it works
struct ContentView1111: View {
var numbers = 1...100
var body: some View {
VStack {
FittedScrollView(){
AnyView(
LazyHStack {
ForEach(numbers, id: \.self) {
Text("\($0)")
.font(.largeTitle)
}
}
)
}
// you can see it on screenshot - scrollView size
.background(Color.purple)
Spacer()
}
// you can see it on screenshot - other background
.background(Color.green)
}
}
struct HeightPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: CGFloat = 40
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct FittedScrollView: View {
var content: () -> AnyView
#State private var contentHeight: CGFloat = 40
var body: some View {
VStack {
ScrollView(.horizontal) {
content()
.overlay(
GeometryReader { geo in
Color.clear
.preference(key: HeightPreferenceKey.self, value: geo.size.height)
})
}
.frame(height: contentHeight)
}
.onPreferenceChange(HeightPreferenceKey.self) {
contentHeight = $0
}
}
}

Align Views by constraints

I would like to build a view similar to this
✓ **Title Text**
Description Text
Where the icon an the title text have the same horizontal center ant the title text and the description text have the same alginment at the left.
Since i could not find any possiblity in SwiftUI to set constraints I am a little bit stuck.
The best solution i could come up was this
HStack(alignment: .top, spacing: Constants.Stacks.defaultHorizontalSpacing) {
challengeTask.status.getIconImage()
VStack(alignment: .leading, spacing: Constants.Stacks.defaultVerticalSpacing) {
Text(challengeTask.title)
.titleText()
Text(challengeTask.description)
.multilineTextAlignment(.leading)
.descriptionText()
Spacer()
}
}
But this does not align the icon horiztontally with the title text
iOS 16
struct ContentView: View {
var body: some View {
HStack(alignment: .firstTextBaseline) {
// ^^^^^^^^^^^^^^^^^
Image(systemName: "checkmark")
VStack {
Text("Title").font(.title.bold())
Text("Content").multilineTextAlignment(.leading)
}
}
}
}
Using hidden
struct ContentView: View {
var body: some View {
VStack {
HStack {
iconImage()
Text("Title").font(.title.bold())
}
HStack {
iconImage().hidden()
Text("Content").multilineTextAlignment(.leading)
}
}
}
private func iconImage() -> some View {
// Change it to your image
Image(systemName: "checkmark")
}
}
Using GeometryReader
struct ContentView: View {
#State private var iconSize: CGFloat = .zero
var body: some View {
VStack {
HStack(spacing: 10) {
Image(systemName: "checkmark")
.readSize { iconSize = $0.width }
Text("Title")
.font(.title.bold())
}
Text("Content")
.padding(.leading, iconSize + 10)
}
}
}
fileprivate struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
modifier(ReadSize(onChange))
}
}
fileprivate struct ReadSize: ViewModifier {
let onChange: (CGSize) -> Void
init(_ onChange: #escaping (CGSize) -> Void) {
self.onChange = onChange
}
func body(content: Content) -> some View {
content.background(
GeometryReader { geometryProxy in
Color.clear.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
}
Hope this help!
I think the right way to deal with this is using a custom alignment as described here.
In your case you have 2 possibilities depending on how you want to implement the UI hierarchy:
Align image center with title center
Align description leading with title leading
Playground for both versions:
import SwiftUI
import PlaygroundSupport
extension HorizontalAlignment {
private struct TitleAndDescriptionAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.center]
}
}
static let titleAndDescriptionAlignmentGuide = HorizontalAlignment(
TitleAndDescriptionAlignment.self
)
}
extension VerticalAlignment {
private struct ImageAndTitleCenterAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[VerticalAlignment.center]
}
}
static let imageAndTitleCenterAlignmentGuide = VerticalAlignment(
ImageAndTitleCenterAlignment.self
)
}
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
VStack(alignment: .titleAndDescriptionAlignmentGuide) {
HStack {
Image(systemName: "rosette")
Text("Title text")
.font(.largeTitle)
.alignmentGuide(.titleAndDescriptionAlignmentGuide) { context in
context[.leading]
}
}
Text("Description text")
.alignmentGuide(.titleAndDescriptionAlignmentGuide) { context in
context[.leading]
}
}
HStack(alignment: .imageAndTitleCenterAlignmentGuide) {
Image(systemName: "rosette")
.alignmentGuide(.imageAndTitleCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
VStack(alignment: .leading) {
Text("Title text")
.font(.largeTitle)
.alignmentGuide(.imageAndTitleCenterAlignmentGuide) { context in
context[VerticalAlignment.center]
}
Text("Description text")
}
}
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())

Tap anywhere to add multiple items on canvas

import SwiftUI
struct Level1: View {
#State var tapScore = 0
#State var showingMinedHammer = false
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
#State private var location = CGPoint.zero // < here !!
func mine() {
tapScore += 1
showMinedHammer()
}
func showMinedHammer() {
self.showingMinedHammer = true
DispatchQueue.main.asyncAfter(deadline: .now() + 99) {
self.showingMinedHammer = false
}
}
var body: some View {
GeometryReader { geometryProxy in
ZStack {
Image("hammer.fill").resizable().frame(width: UIScreen.main.bounds.height * 1.4, height: UIScreen.main.bounds.height)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.location = value.location // < here !!
self.mine()
})
if self.showingMinedHammer {
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(self.location) // < here !!
}
}
}.edgesIgnoringSafeArea(.all)
}
}
struct Level1_Previews: PreviewProvider {
static var previews: some View {
Level1()
}
}
struct GetTapLocation:UIViewRepresentable {
var tappedCallback: ((CGPoint) -> Void)
func makeUIView(context: UIViewRepresentableContext<GetTapLocation>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tappedCallback: ((CGPoint) -> Void)
init(tappedCallback: #escaping ((CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
#objc func tapped(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: gesture.view)
self.tappedCallback(point)
}
}
func makeCoordinator() -> GetTapLocation.Coordinator {
return Coordinator(tappedCallback:self.tappedCallback)
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<GetTapLocation>) {
}
}
New to SwiftUI and I am trying to combine gestures that allow me to tap anywhere on the screen to add an infinite amount of "Images", but currently the image only stays on screen for a short while. Where am I going wrong? Am I supposed to combine another gesture to get the item to stay on screen whilst adding?
You only have a singular location, so only one item will ever appear in your example. To have multiple items, you'll need some sort of collection, like an Array.
The following is a pared-down example showing using an array:
struct Hammer: Identifiable {
var id = UUID()
var location: CGPoint
}
struct Level1: View {
#State var hammers: [Hammer] = [] //<-- Start with `none`
var body: some View {
ZStack {
ForEach(hammers) { hammer in // Display all of the hammers
Image(systemName: "hammer.fill")
.resizable()
.frame(width: 30, height: 30)
.position(hammer.location)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
self.hammers.append(Hammer(location: value.location)) // Add a Hammer
})
.edgesIgnoringSafeArea(.all)
}
}
Note: I'm unclear on what the GeometryReader is for in your code -- you declare it, then use UIScreen dimensions -- normally in SwiftUI we just use GeometryReader

SwiftUI show custom sheet over tab bar

I have custom tab bar with two screen and I created a modifier to display custom sheet from bottom.
I don't want to use .sheet cause I can't change cornerRadius and also I want my sheet to be self-sized (dynamique height).
MainView:
struct MainView: View {
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $currentTab) {
Screen1()
.tag(Tab.screen1)
Screen2()
.tag(Tab.screen2)
}
.tabViewStyle(DefaultTabViewStyle())
HStack {
TabItemView(currentTab: $currentTab, tab: .screen1)
TabItemView(currentTab: $currentTab, tab: .screen2)
}
}
}
struct TabItemView: View {
#Binding var currentTab: Tab
var tab: Tab
var body: some View {
Button {
currentTab = tab
} label: {
VStack {
Image(tab.image)
.resizable()
.frame(width: 30, height: 30)
.frame(maxWidth: .infinity)
Text(tab.rawValue)
}.padding(.vertical, 10)
}
}
}
Custom BottomSheet :
struct BottomSheet<Content: View>: View {
#Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let content: Content
init(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, content: #escaping () -> Content) {
self._isPresented = isPresented
self.onDismiss = onDismiss
self.content = content()
}
var isiPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
public var body: some View {
ZStack {
if isPresented {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.transition(.opacity)
.zIndex(1)
.onTapGesture {
dismiss()
}
container
.ignoresSafeArea(.container, edges: .bottom)
.transition(isiPad ? AnyTransition.opacity.combined(with: .offset(x: 0, y: 200)) : .move(edge: .bottom))
.zIndex(2)
}
}.animation(.spring(response: 0.35, dampingFraction: 1), value: isPresented)
}
private var container: some View {
VStack {
Spacer()
if isiPad {
card.aspectRatio(1.0, contentMode: .fit)
Spacer()
} else {
card
}
}
}
private var card: some View {
VStack(alignment: .trailing, spacing: 0) {
content
}
.background(Color.background)
.clipShape(RoundedRectangle(cornerRadius: .radius_xl))
}
func dismiss() {
withAnimation {
isPresented = false
}
onDismiss?()
}
}
extension View {
func bottomSheet<Content: View>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, backgroundColor: Color = Color(.systemGray6), #ViewBuilder content: #escaping () -> Content) -> some View {
ZStack {
self
BottomSheet(isPresented: isPresented, onDismiss: onDismiss) { content() }
}
}
}
Screen 1:
struct Screen1: View {
...
#State var isPresented = false
...
var body: some View {
Button(action: {
isPresented = true
}, label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Add")
Spacer()
}
})
.bottomSheet(isPresented: $isPresented) {
PopupView(isPresented: $isPresented, message: "") {
//Action when user confirm
}
}
}
}
The problem is bottom sheet is presented behind my tab bar, when I move my .bottomSheet modifier to MainView it's working perfectly but in my case I want to use .bottomSheet in child views cause I have actions to do when user tap confirm or cancel button.
PS: I want to mimic .sheet behavior, always presenting view on top of root

SwiftUI - Expanding the main grid horizontally pushes my other views offscreen

I am working on an iPhone app using SwiftUI. The main grid uses HStacks nested inside a VStack. The number of columns will be variable on initialization, but fixed thereafter. The number of rows is completely dynamic. When the grid expands beyond the width or height of the screen it pushes the other views out of the way.
I would like it to just expand rightward (on initialization) and downward (dynamically) offscreen and behind the other views. I tried putting everything in a ZStack and setting their zindices, but that didn't work. Is there any way to do this or do I need a new approach?
//
// GameView.swift
// Scoreboard
//
// Created by user926153 on 8/21/20.
// Copyright © 2020 user926153. All rights reserved.
//
//
//
/*
This uses the TrackableScrollView as created by Max Natchanon here:
https://medium.com/#maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec
*/
import SwiftUI
import UIKit
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
#Binding var contentOffset: CGFloat
let content: Content
init(_ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.axes = axes
self.showIndicators = showIndicators
self._contentOffset = contentOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
// Send value to the parent
}
VStack {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.contentOffset = value[0]
}
// Get the value then assign to offset binding
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return (outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY) * -1
} else {
return (outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX) * -1
}
}
}
struct GameView: View {
let row_label_offset: CGFloat = 80
let col_width: CGFloat = 75
let row_height: CGFloat = 50
#ObservedObject var settings: GameSettings
#State var round_number: Int = 1
#State var scores: [[Int]] = [[0, 0, 0, 0]]
#State var column_offset: CGFloat = 0
#State var row_offset: CGFloat = 0
private func AddRound() {
self.round_number += 1
self.scores.append([0, 0, 0, 0])
}
private func DeleteRound(at offsets: IndexSet) {
// NOTE: also have to delete round from score array
self.round_number -= 1
self.scores.remove(atOffsets: offsets)
}
var body: some View {
VStack {
VStack(alignment: .center, spacing: 10) {
Text("Title")
.font(.title)
Text("subtitle")
}
.border(Color.black)
VStack(alignment: .leading, spacing: 10) {
HStack {
Button(action: {
self.AddRound()
}) {
Text("New round +")
.fixedSize(horizontal: false, vertical: true)
}
.frame(width: self.row_label_offset, height: self.row_height)
TrackableScrollView(.horizontal, showIndicators: false, contentOffset: $column_offset) {
HStack {
ForEach((1...7), id: \.self) {
Text("Player \($0)\n total")
.frame(width: self.col_width, height: self.row_height)
}
}
}
.frame(height: 50)
.border(Color.red)
}
HStack {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $row_offset) {
ForEach((1...self.round_number), id: \.self) { round in
Text("Round \(round)")
.frame(width: self.col_width, height: self.row_height)
}
}
.border(Color.black)
.frame(width: self.row_label_offset)
VStack {
ForEach(self.scores, id: \.self) { round_score in
HStack {
ForEach(round_score, id: \.self) { score in
Text("\(score)")
.frame(width: self.col_width, height: self.row_height)
}
}
}
Spacer()
}
.offset(x: self.column_offset, y: self.row_offset)
Spacer()
}
.border(Color.blue)
}
Button(action: {
}) {
Text("Finish Game")
}
Spacer()
}
}
}
#if DEBUG
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView(settings: GameSettings())
}
}
#endif
Here is fixed part - to avoid layout corruption you have to move dynamic part out-of-current-layout, eg. in overlay of empty space area. Also some minor fixes added, like clipping and alignment.
Tested with Xcode 12 / iOS 14
HStack {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $row_offset) {
ForEach((1...self.round_number), id: \.self) { round in
Text("Round \(round)")
.frame(width: self.col_width, height: self.row_height)
}
}
.border(Color.black)
.frame(width: self.row_label_offset)
Color.clear.overlay ( // << from here !!
VStack {
ForEach(self.scores, id: \.self) { round_score in
HStack {
ForEach(round_score, id: \.self) { score in
Text("\(score)")
.frame(width: self.col_width, height: self.row_height)
}
}
}
Spacer()
}
.offset(x: self.column_offset, y: self.row_offset)
, alignment: .topLeading)
.clipped()
}
.border(Color.blue)
}

Resources