SwiftUI ScrollView: How to modify .content.offset aka Paging? - scrollview

Problem
How can I modify the scroll target of a scrollView? I am looking for kind of a replacement for the "classic" scrollView delegate method
override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
...where we can modfify the targeted scrollView.contentOffset via targetContentOffset.pointee for instance to create a custom paging behaviour.
Or in other words: I do want to create a paging effect in a (horizontal) scrollView.
What I have tried ie. is something like this:
ScrollView(.horizontal, showsIndicators: true, content: {
HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
card(title: "1")
card(title: "2")
card(title: "3")
card(title: "4")
})
})
// 3.
.content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
// 4.
.animation(self.dragState.isDragging == true ? nil : Animation.spring())
// 5.
.gesture(horizontalDragGest)
Attempt
This is what I tried (besides a custom scrollView approach):
A scrollView has a content area larger then screen space to enable scrolling at all.
I created a DragGesture() to detect if there is a drag going on. In the .onChanged and .onEnded closures I modified my #State values to create a desired scrollTarget.
Conditionally fed in both the original unchanged and the new modified values into the .content.offset(x: y:) modifier - depending on the dragState as a replacement for missing scrollDelegate methods.
Added animation acting conditionally only when drag has ended.
Attached the gesture to the scrollView.
Long story short. It doesn't work.
I hope I got across what my problem is.
Any solutions out there? Looking forward to any input. Thanks!

I have managed to achieve a paging behaviour with a #Binding index. The solution might look dirty, I'll explain my workarounds.
The first thing I got wrong, was to get alignment to .leading instead of the default .center, otherwise the offset works unusual. Then I combined the binding and a local offset state. This kinda goes against the "Single source of truth" principle, but otherwise I had no idea how to handle external index changes and modify my offset.
So, my code is the following
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [Content]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
page
.frame(width: geometry.size.width, height: nil)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture().onChanged({ value in
// 4
self.isGestureActive = true
// 5
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
}).onEnded({ value in
if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
self.index += 1
}
if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
self.index -= 1
}
// 6
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
// 7
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
}
you may just wrap your content, I used it for "Tutorial Views".
this a trick to switch between external and internal state changes
.leading is mandatory if you don't want to translate all offsets to center.
set the state to local state change
calculate the full offset from the gesture delta (*-1) plus the previous index state
at the end set the final index based on the gesture predicted end, while rounding the offset up or down
reset the state to handle external changes to index
I have tested it in the following context
struct WrapperView: View {
#State var index: Int = 0
var body: some View {
VStack {
SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
.padding()
}
}
}
where TODOView is my custom view that indicates a view to implement.
I hope I get the question right, if not please specify which part should I focus on. Also I welcome any suggestions to remove the isGestureActive state.

#gujci your solution is perfect, for more general usage, make it accept Models and view builder as in (note the I pass the geometry size in the builder) :
struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [TModel]
var builder : (CGSize, TModel) -> TView
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
self.builder(geometry.size, page)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture().onChanged({ value in
// 4
self.isGestureActive = true
// 5
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
}).onEnded({ value in
if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
self.index += 1
}
if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
self.index -= 1
}
// 6
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
// 7
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
}
and can be used as :
struct WrapperView: View {
#State var index: Int = 0
#State var items : [(color:Color,name:String)] = [
(.red,"Red"),
(.green,"Green"),
(.yellow,"Yellow"),
(.blue,"Blue")
]
var body: some View {
VStack(spacing: 0) {
SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
TODOView(extraInfo: item.model.name)
.frame(width: size.width, height: size.height)
.background(item.model.color)
}
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
}.edgesIgnoringSafeArea(.all)
}
}
with the help of some utilities :
struct MakeIdentifiable<TModel,TID:Hashable> : Identifiable {
var id : TID {
return idetifier(model)
}
let model : TModel
let idetifier : (TModel) -> TID
}
extension Array {
func identify<TID: Hashable>(by: #escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
{
return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
}
}

#gujci, thank you for interesting example. I've played with it and removed the isGestureActive state. Full example may be found in my gist.
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#State private var index: Int = 0
#State private var offset: CGFloat = 0
var pages: [Content]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
page
.frame(width: geometry.size.width, height: nil)
}
}
}
.content.offset(x: self.offset)
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture()
.onChanged({ value in
self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
})
.onEnded({ value in
if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
nextIndex += self.index
self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
}
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
})
)
}
}
}

As far as I know scrolls in swiftUI doesn't support anything potentially useful such as scrollViewDidScroll or scrollViewWillEndDragging yet. I suggest using either classic UIKit views for making very custom behavior and cool SwiftUI views for anything that is easier. I've tried that a lot and it actually works! Have a look at this guide. Hope that helps

Alternative solution would be to integrate UIKit into SwiftUI using UIViewRepresentative which links UIKit components with SwiftUI. For additional leads and resources, see how Apple suggests you interface with UIKit: Interfacing with UIKit. They have a good example that shows to page between images and track selection index.
Edit: Until they (Apple) implement some sort of content offset that effects the scroll instead of the entire view, this is their suggested solution since they knew the initial release of SwiftUI wouldn't encompass all functionality of UIKit.

Details
Xcode 14
Swift 5.6.1
Requirements
I do not want to use integration with UIKit (clean SwiftUI ONLY)
I do not want to scroll to the any "ID", I want to scroll to the point
Solution
import SwiftUI
#available(iOS 14.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
struct ExtendedScrollView<Content>: View where Content: View {
private let contentProvider: _AligningContentProvider<Content>
// Main Idea from: https://github.com/edudnyk/SolidScroll/blob/main/Sources/SolidScroll/ScrollView.swift
private var config: _ScrollViewConfig
init(config: _ScrollViewConfig = _ScrollViewConfig(),
#ViewBuilder content: () -> Content) {
contentProvider = _AligningContentProvider(content: content(), horizontal: .center, vertical: .center)
self.config = config
}
init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
#ViewBuilder content: () -> Content) {
var config = _ScrollViewConfig()
config.showsHorizontalIndicator = axes.contains(.horizontal) && showsIndicators
config.showsVerticalIndicator = axes.contains(.vertical) && showsIndicators
self.init(config: config, content: content)
}
init(config: () -> _ScrollViewConfig,
#ViewBuilder content: () -> Content) {
self.init(config: config(), content: content)
}
var body: some View {
_ScrollView(contentProvider: contentProvider, config: config)
}
}
extension _ContainedScrollViewKey: PreferenceKey {}
// MARK: Track ScrollView Scrolling
struct TrackableExtendedScrollView: ViewModifier {
let onChange: (_ScrollViewProxy?) -> Void
func body(content: Content) -> some View {
content
.onPreferenceChange(_ContainedScrollViewKey.self, perform: onChange)
}
}
extension View {
func onScrollChange(perform: #escaping (_ScrollViewProxy?) -> Void) -> some View {
modifier(TrackableExtendedScrollView(onChange: perform))
}
}
Usage Sample
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
// ....
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: 50)
.frame(maxHeight: .infinity)
}
}
}
.onScrollChange { proxy in
// let offset = proxy?.contentOffset.y
}
Full Sample
Implementation details
First column and first row are always on the screen
There are 3 "CollectionView":
first row "CollectionView"
first column "CollectionView"
main content "CollectionView"
All "CollectionView" are synced (if you scroll one "CollectionView", another will also be scrolled)
Do not forget to paste The Solution code here
import SwiftUI
import Combine
struct ContentView: View {
private let columWidth: CGFloat = 50
private var gridItemLayout0 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout1 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
#State var text: String = "scrolling not detected"
#State private var scrollViewProxy1: _ScrollViewProxy?
#State private var tableContentScrollViewProxy: _ScrollViewProxy?
#State private var tableHeaderScrollViewProxy: _ScrollViewProxy?
private let numberOfColumns = 50
private let numberOfRows = 40
let headerColor = Color(0xEE9B00)
let firstColumnColor = Color(0x0A9396)
let headerTextColor = Color(.white)
let horizontalSpacing: CGFloat = 6
let verticalSpacing: CGFloat = 0
let firstColumnWidth: CGFloat = 100
let columnWidth: CGFloat = 60
var body: some View {
VStack(spacing: 0) {
Text("First column and row are sticked to the content")
.foregroundColor(.gray)
Text(text)
HStack {
Rectangle()
.frame(width: firstColumnWidth-2)
.foregroundColor(.clear)
buildFirstCollectionViewRow()
}
.frame(height: 50)
HStack(alignment: .firstTextBaseline, spacing: horizontalSpacing) {
buildFirstCollectionViewColumn()
buildCollectionViewContent()
}
}
}
#ViewBuilder
private func buildFirstCollectionViewRow() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout1, spacing: horizontalSpacing) {
ForEach((0..<numberOfColumns), id: \.self) {
let color = $0%2 == 0 ? Color(0x005F73) : Color(0xCA6702)
Text("Value\($0)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.foregroundColor(headerTextColor)
.background(color)
.font(.system(size: 16, weight: .semibold))
}
}
}
.onScrollChange { proxy in
if tableHeaderScrollViewProxy != proxy { tableHeaderScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
tableContentScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: header"
}
}
}
// MARK: Collection View Elements
extension ContentView {
#ViewBuilder
private func buildFirstCollectionViewColumn() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows), id: \.self) {
Text("multi line text \($0)")
.foregroundColor(.white)
.lineLimit(2)
.frame(width: firstColumnWidth)
.font(.system(size: 16, weight: .semibold))
.frame(maxHeight: .infinity)
.background(firstColumnColor)
.border(.white)
}
}
}
.frame(width: firstColumnWidth)
.onScrollChange { proxy in
if scrollViewProxy1 != proxy { scrollViewProxy1 = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
tableContentScrollViewProxy?.contentOffset.y = offset
}
text = "scrolling: 1st column"
}
}
#ViewBuilder
private func buildCollectionViewContent() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.background(color)
.border(.white)
}
}
}
.onScrollChange { proxy in
if tableContentScrollViewProxy != proxy { tableContentScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
self.scrollViewProxy1?.contentOffset.y = offset
}
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
self.tableHeaderScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: content"
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
init(_ hex: UInt, alpha: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: alpha
)
}
}
Full Sample Demo

Related

Nested ScrollView SwiftUI

I am trying to put grid(developed with scrollview) inside scrollview but I can't set grid height with content height. I can't handle this problem with .fixedsize()
GeometryReader{ reader in
ScrollView{
Text(reader.size.height.description)
Text(reader.size.height.description)
FlowStack(columns: 3, numItems: 27, alignment: .leading) { index, colWidth in
if index == 0{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
.background(Color.red)
}
else if index == 1{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
else if index == 2{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
else{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
}
.background(Color.red)
Text(reader.size.height.description)
Text(reader.size.height.description)
}.fixedSize(horizontal: false, vertical: false)
}
Result
What I want
I don't know what did you wrote in FlowStack struct, that's why it's really hard to give right answer. There is how I solve this grid:
struct GridScrollView: View {
var body: some View {
GeometryReader{ geometry in
VStack {
Text("height: \(geometry.size.height.description)")
Text("width: \(geometry.size.width.description)")
ScrollView{
FlowStack(columns: 3, numItems: 60, width: geometry.size.width, height: geometry.size.height)
}
}
}
}
}
my FlowStack code:
struct FlowStack: View {
var columns: Int
var numItems: Int
var width: CGFloat
var height: CGFloat
private var numberOfRows: Int {
get {
numItems / columns
}
}
private var cellWidth: CGFloat {
get {
width / CGFloat(columns) - 4 // with padding
}
}
private var cellHeight: CGFloat {
get {
height / CGFloat(numberOfRows) - 4 // with padding
}
}
var body: some View {
ForEach(0..<self.numberOfRows, id: \.self) { row in
HStack {
ForEach(1...self.columns, id: \.self) { col in
Text("\(row * self.columns + col)")
.frame(width: self.cellWidth, height: self.cellHeight, alignment: .center)
.background(Color.red)
.border(Color.gray)
.padding(2)
}
}
}
}
}
and the result is:
P.S. that's my quick solution. I think you can also use this grid from github, it's quite massive, but flexible in my opinion

How do I restrict a dragging gesture to one direction only in SwiftUI?

I've embarrassingly spent the past 2 weeks trying to solve this.
What I'm trying to do is:
Snap my Slide Over View to the bottom of the screen
Disable dragging up and only allow the card to be dragged down to close
What I've tried:
I've tried messing with the size of the card by setting its height to the height of the screen. You can see this line commented out. After doing this, I've messed around with the offset of the card and set it so that it looks as if the card is actually less than half its size, around 300 in height. The problem with this is when I slide up slowly, I can see the empty space that is hidden out of the screen. This isn't the effect I want.
The next thing I have tried to do is change the height of the card to a desired height. Then adjust the offset so the card is where I want it to be. However, I feel manually adjusting it won't be reliable on different screens. So I'm trying to work out the right math needed to always have it be placed at the very bottom of the screen when it pops up.
Finally, I want to just make it so users can only drag down and not up.
I would really appreciate some help here. I've spent a lot of time message around and reading, learning new things, but I can't solve my specific problem.
Here is my Slide Over Card
import SwiftUI
struct SigninView<Content: View> : View {
#GestureState private var dragState = DragState.inactive
#State var position = CardPosition.top
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
// Handle()
self.content()
}
.frame(height: 333) //UIScreen.main.bounds.height)
.background(Color.purple)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position.rawValue + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
let positionAbove: CardPosition
let positionBelow: CardPosition
let closestPosition: CardPosition
if cardTopEdgeLocation <= CardPosition.middle.rawValue {
positionAbove = .top
positionBelow = .middle
} else {
positionAbove = .middle
positionBelow = .bottom
}
if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum CardPosition: CGFloat {
case top = 100
case middle = 790
case bottom = 850
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
Here is my ContentView page, where I test it:
import SwiftUI
struct ContentView: View {
#State var show:Bool = false
var body: some View {
SigninView {
VStack {
Text("TESTING")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
First, you should probably not have your SigninView be your content view. Instead, consider presenting your sign in view as an overlay instead.
var body: some View {
ZStack {
Text("Content here!")
}
.overlay(
SigninView()
.offset(...),
alignment: .bottom
)
}
This will automatically place your view at the bottom of the screen at the height of your SigninView, there should be little to no math involved here. The offset, you will define with your gesture and any space you want to exist between the bottom and your overlay.
Next, to only allow down gestures, can't you just clamp your translation?
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return max(0, translation) // clamp this to the actual translation or 0 so it can't go negative
}
}

SwiftUI Images disappearing when in NavigationLink

I am trying out SwiftUI and I am building a holiday app in which you can see where have you been what was the weather like there and how long did you stay there.
The problem is that when I put my HolidayCard in NavigationLink all images disappear.
This is the code and the preview with navigation link
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
ForEach(holidayData, id: \.id) { holiday in
NavigationLink(destination: HolidayDetails(holiday: holiday)) {
HolidayCard(holiday: holiday)
}
}
}
.navigationBarTitle(Text("My holidays"), displayMode: .large)
}
}
}
This is my HolidayCard and how should it look like
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 12) {
Spacer()
holiday.weatherImage
.resizable()
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 0) {
Text("\(holiday.city),")
.font(.headline)
.fontWeight(.bold)
Text(holiday.country)
.font(.subheadline)
}
Text("\(holiday.season.rawValue) · \(holiday.duration) days")
.font(.headline)
.fontWeight(.light)
}
.foregroundColor(Color.white)
.padding()
.background(Color.black.opacity(0.5))
Spacer()
}
.frame(width: UIScreen.main.bounds.width - 20, height: 200)
.background(
holiday.cityImage
.resizable()
.aspectRatio(contentMode: .fill)
)
.cornerRadius(10)
.padding(10)
.padding(.top, 30)
.shadow(color: Color.gray, radius: 6, x: 3, y: 6)
}
HolidayDetails
struct HolidayDetails: View {
var holiday: Holiday
#State var moreDescription = false
var body: some View {
ScrollView {
VStack {
ZStack(alignment: .bottomLeading) {
holiday.cityImage
.resizable()
.frame(height: moreDescription ? UIScreen.main.bounds.height * 0.3 : UIScreen.main.bounds.height * 0.6)
.aspectRatio(contentMode: .fill)
.frame(width: UIScreen.main.bounds.width)
.animation(.easeInOut)
HStack {
VStack(alignment: .leading, spacing: 12) {
holiday.weatherImage
.resizable()
.frame(width: 64, height: 64)
VStack(alignment: .leading, spacing: 0) {
Text("\(holiday.city),")
.font(.largeTitle)
.fontWeight(.bold)
Text(holiday.country)
.font(.title)
}
Text("\(holiday.season.rawValue) · \(holiday.duration) days")
.font(.headline)
.fontWeight(.light)
}
.foregroundColor(Color.white)
.animation(.easeInOut)
.padding()
}
}
VStack(alignment: .center, spacing: 12) {
Text(holiday.description)
.font(moreDescription ? .subheadline : .caption)
.lineLimit(moreDescription ? 99 : 2)
.opacity(moreDescription ? 1 : 0.5)
.animation(.easeInOut)
.padding()
Button(action: {
withAnimation(.easeInOut(duration: 3)) {
self.moreDescription.toggle()
}
}) {
Text(moreDescription ? "Less.." : "More..")
.font(.caption)
.padding(.vertical, moreDescription ? 10 : 5)
.padding(.horizontal, moreDescription ? 30 : 15)
.background(Color.blue)
.foregroundColor(Color.white)
.animation(.easeInOut)
}
.cornerRadius(10)
}
Spacer()
}
}
}
}
Holiday model
import Foundation
import SwiftUI
struct Holiday: Hashable, Codable, Identifiable {
var id: Int
var city: String
var country: String
var description: String
var duration: Int
var weather: Weather
var season: Season
}
extension Holiday {
var weatherImage: Image {
Image("\(self.weather)")
}
var cityImage: Image {
Image(self.city
.replacingOccurrences(of: " ", with: "")
.lowercased())
}
}
enum Weather: String, CaseIterable, Hashable, Codable {
case cloudy = "cloudy"
case rainy = "rainy"
case snowy = "snowy"
case sunny = "sunny"
}
enum Season: String, CaseIterable, Hashable, Codable {
case summer = "Summer"
case winter = "Winter"
case spring = "Spring"
case autumn = "Autumn"
}
And this is how I get holiday populated
import Foundation
let holidayData: [Holiday] = load("holidayData.json")
func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}
Without the Navigation link the background image and the weather image are visible.
Try to put .renderingMode(.original) in your Image
Try to add this parameter to your NaviguationLink or Button
.buttonStyle(PlainButtonStyle())

How to animate tabbar items (on selection) in SwiftUI?

How can I animate Tabbar Items (of a TabView) on selection in SwiftUI?
for example give the selected item a .scaleEffect() with .spring() animation
or sth like below:
This is what I've tried so far:
struct MyTabbedView: View {
#State var enlargeIt1 = false
#State var enlargeIt2 = true
var body: some View {
TabView {
Text("Item 1")
.onAppear {
self.enlargeIt1.toggle()
self.enlargeIt2.toggle()
}
.tabItem{
VStack{
Image(systemName: "music.note")
.font(self.enlargeIt1 ? .system(size: 30) : .system(size: 15) )
.animation(.interpolatingSpring(mass: 0.7, stiffness: 200, damping: 10, initialVelocity: 4))
Text("Music")
}
}.tag(1)
Text("Item 2")
.onAppear {
self.enlargeIt1.toggle()
self.enlargeIt2.toggle()
}
.tabItem{
VStack{
Image(systemName: "music.mic")
.font(self.enlargeIt2 ? .system(size: 30) : .system(size: 15) )
.animation(.interpolatingSpring(mass: 0.7, stiffness: 200, damping: 10, initialVelocity: 4))
Text("Mic")
}
}.tag(2)
}
}
}
and the result is this:
I tried approximately the same code in a separate View called TestView :
struct TestView: View {
#State var enlargeIt1 : Bool = false
var body: some View {
VStack{
Image(systemName: "music.note")
.font(self.enlargeIt1 ? .system(size: 30) : .system(size: 15) )
.animation(.interpolatingSpring(mass: 0.7, stiffness: 200, damping: 10, initialVelocity: 4))
Text("Music")
}
.onTapGesture {
self.enlargeIt1.toggle()
}
}
}
and this is the result:
What's wrong with the TabView I've created that it's not giving the same result?
Here is possible approach for standard TabView (for provided code snapshot).
The idea is to use animatable modifier for font size over used SF images.
Tested with Xcode 11.4 / iOS 13.4
// Animating font size
struct AnimatableSFImage: AnimatableModifier {
var size: CGFloat
var animatableData: CGFloat {
get { size }
set { size = newValue }
}
func body(content: Self.Content) -> some View {
content
.font(.system(size: size))
}
}
// helper extension
extension Image {
func animatingSF(size: CGFloat) -> some View {
self.modifier(AnimatableSFImage(size: size))
}
}
// Modified test code snapshot
struct TestAnimatedTabBar: View {
#State var enlargeIt1 = false
#State var enlargeIt2 = true
var body: some View {
TabView {
Text("Item 1")
.onAppear {
self.enlargeIt1.toggle()
self.enlargeIt2.toggle()
}
.tabItem{
VStack{
Image(systemName: "music.note")
.animatingSF(size: self.enlargeIt1 ? 30 : 15 )
Text("Music")
}.animation(.interpolatingSpring(mass: 0.7,
stiffness: 200, damping: 10, initialVelocity: 4))
}.tag(1)
Text("Item 2")
.onAppear {
self.enlargeIt1.toggle()
self.enlargeIt2.toggle()
}
.tabItem{
VStack{
Image(systemName: "music.mic")
.animatingSF(size: self.enlargeIt2 ? 30 : 15 )
Text("Mic")
}.animation(.interpolatingSpring(mass: 0.7,
stiffness: 200, damping: 10, initialVelocity: 4))
}.tag(2)
}
}
}
Someone created a custom TabView that might be of help to you. I'm sure you could modify it to suit your needs.
BottomBar component for SwiftUI inspired by this concept
https://github.com/smartvipere75/bottombar-swiftui
import SwiftUI
import BottomBar_SwiftUI
let items: [BottomBarItem] = [
BottomBarItem(icon: "house.fill", title: "Home", color: .purple),
BottomBarItem(icon: "heart", title: "Likes", color: .pink),
BottomBarItem(icon: "magnifyingglass", title: "Search", color: .orange),
BottomBarItem(icon: "person.fill", title: "Profile", color: .blue)
]
struct BasicView: View {
let item: BottomBarItem
var detailText: String {
"\(item.title) Detail"
}
var followButton: some View {
Button(action: openTwitter) {
VStack {
Text("Developed by Bezhan Odinaev")
.font(.headline)
.color(item.color)
Text("#smartvipere75")
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
var destination: some View {
Text(detailText)
.navigationBarTitle(Text(detailText))
}
var navigateButton: some View {
NavigationLink(destination: destination) {
ZStack {
Rectangle()
.fill(item.color)
.cornerRadius(8)
.frame(height: 52)
.padding(.horizontal)
Text("Navigate")
.font(.headline)
.foregroundColor(.white)
}
}
}
func openTwitter() {
guard let url = URL(string: "https://twitter.com/smartvipere75") else {
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
var body: some View {
VStack {
Spacer()
followButton
Spacer()
navigateButton
}
}
}
struct ContentView : View {
#State private var selectedIndex: Int = 0
var selectedItem: BottomBarItem {
items[selectedIndex]
}
var body: some View {
NavigationView {
VStack {
BasicView(item: selectedItem)
.navigationBarTitle(Text(selectedItem.title))
BottomBar(selectedIndex: $selectedIndex, items: items)
}
}
}
}

Swiftui how to use MKOverlayRenderer?

I want draw a route on the map.
but struct without using delegate.
struct MapView : UIViewRepresentable {
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
}
how can I do?
You need to specify a delegate if you want mapView(_:rendererFor:) to be called:
struct MapView: UIViewRepresentable {
#Binding var route: MKPolyline?
let mapViewDelegate = MapViewDelegate()
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
view.delegate = mapViewDelegate // (1) This should be set in makeUIView, but it is getting reset to `nil`
view.translatesAutoresizingMaskIntoConstraints = false // (2) In the absence of this, we get constraints error on rotation; and again, it seems one should do this in makeUIView, but has to be here
addRoute(to: view)
}
}
private extension MapView {
func addRoute(to view: MKMapView) {
if !view.overlays.isEmpty {
view.removeOverlays(view.overlays)
}
guard let route = route else { return }
let mapRect = route.boundingMapRect
view.setVisibleMapRect(mapRect, edgePadding: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10), animated: true)
view.addOverlay(route)
}
}
class MapViewDelegate: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.fillColor = UIColor.red.withAlphaComponent(0.5)
renderer.strokeColor = UIColor.red.withAlphaComponent(0.8)
return renderer
}
}
Used like so:
struct ContentView : View {
#State var route: MKPolyline?
var body: some View {
MapView(route: $route)
.onAppear {
self.findCoffee()
}
}
}
private extension ContentView {
func findCoffee() {
let start = CLLocationCoordinate2D(latitude: 37.332693, longitude: -122.03071)
let region = MKCoordinateRegion(center: start, latitudinalMeters: 2000, longitudinalMeters: 2000)
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = "coffee"
request.region = region
MKLocalSearch(request: request).start { response, error in
guard let destination = response?.mapItems.first else { return }
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: start))
request.destination = destination
MKDirections(request: request).calculate { directionsResponse, _ in
self.route = directionsResponse?.routes.first?.polyline
}
}
}
}
Yielding: