├── .editorconfig ├── .swiftlint.yml ├── AlphaVantage ├── API.swift ├── AlphaVantage.h ├── Info.plist └── IntradayTimeSeries.swift ├── CHANGELOG.md ├── Configuration ├── Debug.xcconfig ├── Default.xcconfig └── Release.xcconfig ├── Documentation └── SwiftUI_Pull_to Refresh_iOS_13_iOS_14.png ├── OpenWeather ├── API.swift ├── Extensions │ └── Bundle+Extensions.swift ├── HourlyForecast+Preview.swift ├── HourlyForecast.json ├── HourlyForecast.swift ├── Info.plist ├── Location.swift └── OpenWeather.h ├── README.md ├── SwiftUI_Pull_to_Refresh.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcshareddata │ └── xcschemes │ │ └── SwiftUI_Pull_to_Refresh.xcscheme └── xcuserdata │ └── eppz.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── SwiftUI_Pull_to_Refresh ├── AppDelegate.swift ├── AppHostingController.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Color │ │ ├── Background.colorset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Dark Gray.colorset │ │ │ └── Contents.json │ │ ├── Foreground.colorset │ │ │ └── Contents.json │ │ ├── Gray.colorset │ │ │ └── Contents.json │ │ ├── Green.colorset │ │ │ └── Contents.json │ │ └── Medium Gray.colorset │ │ │ └── Contents.json │ ├── Contents.json │ └── Image │ │ ├── Contents.json │ │ ├── Opaque World Map with Blur.imageset │ │ ├── Contents.json │ │ ├── Opaque World Map with Blur.png │ │ ├── Opaque World Map with Blur@2x.png │ │ └── Opaque World Map with Blur@3x.png │ │ ├── Opaque World Map.imageset │ │ ├── Contents.json │ │ ├── Opaque World Map.png │ │ ├── Opaque World Map@2x.png │ │ └── Opaque World Map@3x.png │ │ ├── Prototypes │ │ ├── 10pt.imageset │ │ │ ├── Contents.json │ │ │ ├── Square.png │ │ │ ├── Square@2x.png │ │ │ └── Square@3x.png │ │ ├── Contents.json │ │ └── Map.imageset │ │ │ ├── Contents.json │ │ │ ├── Map.png │ │ │ ├── Map@2x.png │ │ │ └── Map@3x.png │ │ └── WorldMap.imageset │ │ ├── Contents.json │ │ ├── WorldMap.png │ │ ├── WorldMap@2x.png │ │ └── WorldMap@3x.png ├── Base.lproj │ └── LaunchScreen.storyboard ├── Deprecated │ ├── Extensions │ │ ├── UIResponder+Extensions.swift │ │ └── UIView+Extensions.swift │ ├── OnScroll.swift │ ├── PagingModifier.swift │ ├── RefreshControl.swift │ ├── RefreshControlModifier.swift │ ├── ScrollViewMatcher │ │ ├── FramePreferenceKey.swift │ │ └── ScrollViewMatcher.swift │ └── ScrollViewResolver │ │ ├── ContentView.swift │ │ └── RefreshControl.swift ├── Examples │ ├── AsyncAwaitModifierView.swift │ └── ClosureBasedModifierView.swift ├── Extensions │ ├── UIScrollView+Extensions.swift │ ├── UIScrollView.m │ └── Withable.swift ├── Info.plist ├── Lato │ ├── Lato-Black.ttf │ ├── Lato-Bold.ttf │ ├── Lato-Light.ttf │ ├── Lato-Regular.ttf │ ├── Lato-Thin.ttf │ └── OFL.txt ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Prototypes │ ├── IntrospectView.swift │ ├── RefreshControlClosureView.swift │ ├── RefreshControlExtensionView.swift │ ├── RefreshControlTargetView.swift │ ├── ScrollViewExtensionView.swift │ ├── Sketches │ │ ├── FitTextView.swift │ │ ├── HeaderView.swift │ │ ├── MarqueeView.swift │ │ ├── PaddingView.swift │ │ └── TransparentTabView.swift │ ├── StaticRefreshControlView.swift │ └── ViewModel.swift ├── SceneDelegate.swift └── Views │ ├── Background │ ├── AlignedBackgroundView.swift │ └── BackgroundView.swift │ ├── CitiesView.swift │ ├── CitiesViewModel.swift │ ├── City │ ├── CityView.swift │ ├── CityViewModel.swift │ ├── Extensions │ │ └── View+Introspect.swift │ ├── OpenWeather │ │ ├── Mocks │ │ │ ├── Honolulu.json │ │ │ ├── London.json │ │ │ ├── Moscow.json │ │ │ ├── New Delhi.json │ │ │ ├── New York.json │ │ │ ├── Paris.json │ │ │ ├── San Francisco.json │ │ │ ├── Sidney.json │ │ │ └── Tokyo.json │ │ ├── OpenWeather+Extensions.swift │ │ └── OpenWeather+Mocks.swift │ ├── Summary │ │ ├── Attributes │ │ │ ├── AttributeView.swift │ │ │ └── AttributesView.swift │ │ ├── SummaryView.swift │ │ └── TemperatureView.swift │ ├── TitleView.swift │ └── WeatherList │ │ ├── Forecast │ │ ├── ForecastView.swift │ │ ├── ForecastViewModel.swift │ │ ├── TemperatureBarView.swift │ │ └── WindBarView.swift │ │ ├── WeatherList.swift │ │ ├── WeatherListRowView.swift │ │ └── WeatherListViewModel.swift │ └── UI.swift └── SwiftUI_Pull_to_RefreshUITests ├── Info.plist ├── SwiftUI_Pull_to_RefreshUITests.swift └── XCTestCase+Extensions.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | trailing_whitespace: 2 | ignores_empty_lines: true 3 | 4 | vertical_whitespace: 5 | max_empty_lines: 2 6 | 7 | line_length: 8 | warning: 200 9 | error: 1000 10 | 11 | type_name: 12 | min_length: 2 13 | allowed_symbols: "_" 14 | 15 | identifier_name: 16 | min_length: 2 17 | allowed_symbols: "_" 18 | 19 | nesting: 20 | type_level: 4 21 | 22 | disabled_rules: 23 | - weak_delegate 24 | - private_over_fileprivate 25 | 26 | excluded: 27 | - SwiftUI_Pull_to_Refresh/Extensions 28 | -------------------------------------------------------------------------------- /AlphaVantage/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public enum APIError: Error { 12 | case wrongUrl 13 | case noData 14 | } 15 | 16 | 17 | /// From https://www.alphavantage.co/documentation/#intraday 18 | public struct API { 19 | 20 | public static func get(symbol: String = "AAPL", completion: @escaping (_ result: Result) -> Void) { 21 | 22 | // Query. 23 | var components = URLComponents() 24 | components.scheme = "https" 25 | components.host = "www.alphavantage.co" 26 | components.path = "/query" 27 | components.queryItems = [ 28 | .init(name: "function", value: "TIME_SERIES_INTRADAY"), 29 | .init(name: "symbol", value: symbol), 30 | .init(name: "interval", value: "1min"), 31 | .init(name: "outputsize", value: "compact"), 32 | .init(name: "apikey", value: "AAPNSNB308FRK2Z9") // Be my guest 33 | ] 34 | guard let url = components.url else { 35 | return completion(.failure(APIError.wrongUrl)) 36 | } 37 | 38 | // Request. 39 | var request = URLRequest(url: url) 40 | request.httpMethod = "GET" 41 | 42 | // Task. 43 | URLSession(configuration: URLSessionConfiguration.ephemeral).dataTask( 44 | with: request, 45 | completionHandler: { data, _, error in 46 | DispatchQueue.main.async { 47 | if let error = error { 48 | print("error: \(error)") 49 | completion(.failure(error)) 50 | } else if let data = data { 51 | do { 52 | let response = try JSONDecoder().decode(IntradayTimeSeries.self, from: data) 53 | completion(.success(response)) 54 | } catch { 55 | print("error: \(error)") 56 | completion(.failure(error)) 57 | } 58 | } else { 59 | print("error: \(APIError.noData)") 60 | completion(.failure(APIError.noData)) 61 | } 62 | } 63 | } 64 | ).resume() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /AlphaVantage/AlphaVantage.h: -------------------------------------------------------------------------------- 1 | // 2 | // AlphaVantage.h 3 | // AlphaVantage 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for AlphaVantage. 11 | FOUNDATION_EXPORT double AlphaVantageVersionNumber; 12 | 13 | //! Project version string for AlphaVantage. 14 | FOUNDATION_EXPORT const unsigned char AlphaVantageVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /AlphaVantage/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /AlphaVantage/IntradayTimeSeries.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntradayTimeSeries.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// From https://www.alphavantage.co/documentation/#intraday 12 | public struct IntradayTimeSeries: Decodable { 13 | 14 | let metaData: MetaData 15 | let timeSeries: TimeSeries 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case metaData = "Meta Data" 19 | case timeSeries = "Time Series (1min)" 20 | } 21 | 22 | struct MetaData: Decodable { 23 | 24 | let information: String 25 | let symbol: String 26 | let lastRefreshed: String 27 | let interval: String 28 | let outputSize: String 29 | let timeZone: String 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case information = "1. Information" 33 | case symbol = "2. Symbol" 34 | case lastRefreshed = "3. Last Refreshed" 35 | case interval = "4. Interval" 36 | case outputSize = "5. Output Size" 37 | case timeZone = "6. Time Zone" 38 | } 39 | } 40 | 41 | struct TimeSeries: Decodable { 42 | 43 | let items: [Item] 44 | 45 | private struct DynamicCodingKeys: CodingKey { 46 | 47 | var stringValue: String 48 | var intValue: Int? 49 | 50 | init?(stringValue: String) { 51 | self.stringValue = stringValue 52 | } 53 | 54 | init?(intValue: Int) { 55 | return nil 56 | } 57 | } 58 | 59 | init(items: [Item] = []) { 60 | self.items = items 61 | } 62 | 63 | init(from decoder: Decoder) throws { 64 | let container = try decoder.container(keyedBy: DynamicCodingKeys.self) 65 | var items: [Item] = [] 66 | for key in container.allKeys { 67 | if let key = DynamicCodingKeys(stringValue: key.stringValue) { 68 | let decodedItem = try container.decode(Item.self, forKey: key) 69 | items.append(decodedItem) 70 | } 71 | } 72 | self.items = items 73 | } 74 | 75 | struct Item: Decodable { 76 | 77 | let open: String 78 | let high: String 79 | let low: String 80 | let close: String 81 | let volume: String 82 | let time: String 83 | 84 | enum CodingKeys: String, CodingKey { 85 | case open = "1. open" 86 | case high = "2. high" 87 | case low = "3. low" 88 | case close = "4. close" 89 | case volume = "5. volume" 90 | } 91 | 92 | init(from decoder: Decoder) throws { 93 | let container = try decoder.container(keyedBy: CodingKeys.self) 94 | open = try container.decode(String.self, forKey: CodingKeys.open) 95 | high = try container.decode(String.self, forKey: CodingKeys.high) 96 | low = try container.decode(String.self, forKey: CodingKeys.low) 97 | close = try container.decode(String.self, forKey: CodingKeys.close) 98 | volume = try container.decode(String.self, forKey: CodingKeys.volume) 99 | time = container.codingPath.last?.stringValue ?? "" 100 | } 101 | } 102 | } 103 | } 104 | 105 | 106 | public extension IntradayTimeSeries { 107 | 108 | static let empty: IntradayTimeSeries = IntradayTimeSeries( 109 | metaData: MetaData( 110 | information: "", 111 | symbol: "", 112 | lastRefreshed: "", 113 | interval: "", 114 | outputSize: "", 115 | timeZone: "" 116 | ), 117 | timeSeries: TimeSeries() 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | * 1.10.1 4 | 5 | + Layout tweak for iPhone 11 6 | 7 | * 1.10.0 8 | 9 | + Removed `Refreshable` group (and sources) 10 | + Added `Refreshable` package 11 | 12 | * Feature/Scroll_speed/0.0.3 13 | 14 | + `UIScrollView+Extensions` 15 | + Added deceleration offset method swizzling 16 | + Added `contentOffsetAnimationDuration` customization 17 | 18 | * Feature/iPhone_13/0.0.1 - 0.0.2 19 | 20 | + Updated UI to iPhone 13 dimensions (390pt) 21 | + Updated city coordinates for precise weather info 22 | 23 | * 1.9.1 24 | 25 | + Renamed `Network` to `ViewModel` 26 | 27 | * 1.9.0 28 | 29 | + Use live OpenWeather API 30 | 31 | * Feature/Blur/0.1.1 - 0.1.3 32 | 33 | + Slow animations 34 | + Added `UI.Speed` 35 | + Set `window.layer.speed` at start 36 | + Added `UIScrollView` speed extensions 37 | + `set(decelerationRate:)` 38 | + `set(pagingFriction:)` 39 | + Bouncing deceleration rate is unchanged 40 | 41 | * Feature/Blur/0.1.0 42 | 43 | + Scroll performance optimization 44 | + Added `BackgroundView` and `AlignedBackgroundView` 45 | + Extracted background (and blur) views to pre-baked assets 46 | 47 | * 1.8.0 48 | 49 | + Updated empty state values/images 50 | 51 | * Feature/Forecast_view/0.1.0 52 | 53 | + Added `WindBarView` 54 | + `ForecastView` updates 55 | 56 | * Feature/Forecast_view/0.0.1 - 0.0.3 57 | 58 | + Added `TemperatureBarView` 59 | + Dynamic scale 60 | 61 | * 1.7.1 62 | 63 | + More mock data 64 | 65 | * 1.7.0 66 | 67 | + Renamings, refactor, grouping 68 | + Removed redundant environment modifiers 69 | + Removed unused `state` variable 70 | 71 | * 1.6.5 72 | 73 | + Added `OpenWeather.HourlyForecast.mock` to spare API 74 | 75 | * 1.6.0 - 1.6.2 76 | 77 | + Simplified section header background mask 78 | + iOS 13 79 | + Row `bottomPadding` 80 | 81 | * 1.5.21 - 1.5.25 82 | 83 | + Added configuration files 84 | + Manual layout tests 85 | + iOS 14 (14.4) 86 | + Snowflake symbol compatibility 87 | + Transparent list background 88 | + Removed text capitalization from section header 89 | + iOS 13 (13.5) 90 | + Drop symbol compatibility 91 | + Added `removeTextCase` (only on iOS 14) 92 | + Set entire app to dark mode (correct `UIHostingController` style) 93 | + Ignore bottom safe area on list as well 94 | + List transparency compatibility 95 | 96 | * 1.5.16 - 1.5.20 97 | 98 | + `TemperatureView` (celsius scaling) 99 | + `.infinite` width (and the text stack) 100 | + Temporary celsius value provisioning 101 | 102 | * 1.5.15 103 | 104 | + Added mock views 105 | + Renamings 106 | 107 | * 1.5.8 - 1.5.14 108 | 109 | + Added `FitTextView` prototype 110 | 111 | * 1.5.5 - 1.5.7 112 | 113 | + List 114 | + Row horizontal alignment 115 | + Blur background 116 | + Updated map attribution placement 117 | + Renamings 118 | 119 | * 1.5.4 120 | 121 | + Added `OpenWeather+Extensions` (description, icon) 122 | + Added dynamic `description` and `imageName` to views 123 | 124 | * 1.5.2 125 | 126 | + Extracted `RowView` 127 | + Added row clip shapes, insets 128 | + Removed row separator 129 | + Added gradient mask to rows (beneath section header) 130 | 131 | * 1.5.0 132 | 133 | + Style cleanup 134 | 135 | * 1.4.1 - 1.4.4 136 | 137 | + Renamings 138 | + Update README.md 139 | 140 | * 1.4.0 141 | 142 | + Stable custom background blur composite 143 | + Added `background(:)` to `SummaryView` 144 | + Renamings 145 | 146 | * 1.3.6 - 1.3.7 147 | 148 | + Created `CitiesViewModel` 149 | + Created `citiesFrame` enviroment value 150 | + Updated `List` mask 151 | + Added cover to `WeatherView` background 152 | + With backround offset alignment 153 | 154 | * 1.3.5 155 | 156 | + `CityView` 157 | + Extracted `TitleView` 158 | + Extracted `SummaryView` 159 | + Extracted `WeatherView` (with background mask) 160 | + Extracted `AttributesView` 161 | + Extracted `AttributeView` 162 | + Hidden home indicator 163 | + Renamings 164 | 165 | * 1.3.1 166 | 167 | + Extracted `AttributesView` 168 | + Removed extra header padding 169 | + Folder cleanup 170 | 171 | * 1.3.0 172 | 173 | + Secion header prototypes (padding, transparency) 174 | + https://stackoverflow.com/questions/71526675/transparent-section-header 175 | + https://stackoverflow.com/questions/71534756/remove-extra-padding-above-section-header 176 | 177 | * 1.2.5 - 1.2.6 178 | 179 | + Build attributes view (in weather header) 180 | 181 | * 1.2.0 182 | 183 | + Built city header, weather header 184 | + `OpenWeather` date decoding update 185 | + Added map asset(s) 186 | 187 | * 1.1.2 188 | 189 | + Added `Withable` 190 | + SwiftLint updates 191 | 192 | * 1.1.1 193 | 194 | + Added `Lato` font 195 | 196 | * 1.1.0 197 | 198 | + Updated `CityViewModel` with async `fetch` 199 | + Updated `CityView` to use `refreshable` modifier 200 | + Excluded deprecated files from build 201 | 202 | * 1.0.1 203 | 204 | + Update README.md 205 | + Added entirely unrelated `Marquee` prototype 206 | + From https://twitter.com/Geri_Borbas/status/1504461022219968516 207 | 208 | * 1.0.0 209 | 210 | + Added `Refreshable` (with availability) 211 | 212 | * 0.12.0 213 | 214 | + Added `AsyncAwaitView` 215 | 216 | * 0.11.0 217 | 218 | + Added `ScrollViewResolver` (deprecated) 219 | 220 | * 0.10.0 221 | 222 | + Created `RefreshModifier` implementation (according article update) 223 | + Added `Prototype` views (according article update) 224 | + Renaming, grouping 225 | 226 | * 0.9.0 227 | 228 | + Deployment target to iOS 13 229 | + Added `Introspect` 230 | + Added view to test nested scrollviews with `Introspect` 231 | 232 | * 0.8.21 - 0.8.22 233 | 234 | + Added `onScroll {}` modifier 235 | 236 | * 0.8.18 - 0.8.20 237 | 238 | + Refactor view models, view states 239 | 240 | * 0.8.10 - 0.8.16 241 | 242 | + Refactor scroll view resolving, added `paging()` modifier 243 | 244 | * 0.7.5 - 0.8.3 245 | 246 | + Added AlphaVantage API module 247 | + Added OpenWeather API module 248 | 249 | * 0.6.7 - 0.7.2 250 | 251 | + Prototype resolving `UIScrollView` instances by inspecting runtime view geometry 252 | 253 | * 0.0.0 - 0.6.6 254 | 255 | + Prototype attempting to resolve `UIScrollView` instances by inspecting runtime view hierarchy 256 | -------------------------------------------------------------------------------- /Configuration/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Default.xcconfig" 2 | -------------------------------------------------------------------------------- /Configuration/Default.xcconfig: -------------------------------------------------------------------------------- 1 | IPHONEOS_DEPLOYMENT_TARGET = 13.5 2 | //IPHONEOS_DEPLOYMENT_TARGET = 14.4 3 | //IPHONEOS_DEPLOYMENT_TARGET = 15.2 4 | -------------------------------------------------------------------------------- /Configuration/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Default.xcconfig" 2 | -------------------------------------------------------------------------------- /Documentation/SwiftUI_Pull_to Refresh_iOS_13_iOS_14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/Documentation/SwiftUI_Pull_to Refresh_iOS_13_iOS_14.png -------------------------------------------------------------------------------- /OpenWeather/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // OpenWeather 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public enum APIError: Error { 12 | case wrongUrl 13 | case noData 14 | case wrongJSON 15 | } 16 | 17 | 18 | /// From https://openweathermap.org/api/one-call-api 19 | public struct API { 20 | 21 | public static func get( 22 | at location: Location = .init(latitude: 33.44, longitude: -94.04), 23 | completion: @escaping (_ result: Result) -> Void 24 | ) { 25 | 26 | // Query. 27 | var components = URLComponents() 28 | components.scheme = "https" 29 | components.host = "api.openweathermap.org" 30 | components.path = "/data/2.5/onecall" 31 | components.queryItems = [ 32 | .init(name: "lat", value: String(location.latitude)), 33 | .init(name: "lon", value: String(location.longitude)), 34 | .init(name: "exclude", value: "minutely,daily,alerts"), 35 | .init(name: "appid", value: "9ef3309fd13bb2c83a3cea4b8b68afda") // Be my guest 36 | ] 37 | guard let url = components.url else { 38 | return completion(.failure(APIError.wrongUrl)) 39 | } 40 | 41 | // Request. 42 | var request = URLRequest(url: url) 43 | request.httpMethod = "GET" 44 | 45 | // Task. 46 | URLSession.shared 47 | .dataTask( 48 | with: request, 49 | completionHandler: { data, _, error in 50 | DispatchQueue.main.async { 51 | if let error = error { 52 | print("error: \(error)") 53 | completion(.failure(error)) 54 | } else if let data = data { 55 | do { 56 | let decoder = JSONDecoder() 57 | decoder.dateDecodingStrategy = .secondsSince1970 58 | let response = try decoder.decode(HourlyForecast.self, from: data) 59 | completion(.success(response)) 60 | } catch { 61 | print(String(decoding: data, as: UTF8.self)) 62 | print("error: \(error)") 63 | completion(.failure(error)) 64 | } 65 | } else { 66 | print("error: \(APIError.noData)") 67 | completion(.failure(APIError.noData)) 68 | } 69 | } 70 | } 71 | ).resume() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /OpenWeather/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Extensions.swift 3 | // Sudoku 4 | // 5 | // Created by Geri Borbás on 29/03/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | extension Bundle { 12 | 13 | static var current: Bundle { 14 | class `Class` { } 15 | return Bundle(for: `Class`.self) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /OpenWeather/HourlyForecast+Preview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourlyForecast+Preview.swift 3 | // OpenWeather 4 | // 5 | // Created by Geri Borbás on 20/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public extension HourlyForecast { 12 | 13 | static let preview = from(json: "HourlyForecast") 14 | 15 | static func from(json jsonFileName: String) -> HourlyForecast { 16 | guard let path = Bundle.current.path(forResource: jsonFileName, ofType: "json") else { 17 | preconditionFailure("Could not find `\(jsonFileName).json` in bundle.") 18 | } 19 | do { 20 | if let data = try String(contentsOfFile: path).data(using: .utf8) { 21 | let decoded = try JSONDecoder().decode(HourlyForecast.self, from: data) 22 | return decoded 23 | } else { 24 | preconditionFailure("Could not read `\(jsonFileName).json`.") 25 | } 26 | } catch { 27 | preconditionFailure("Could not decode `\(jsonFileName).json`. \(error)") 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /OpenWeather/HourlyForecast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HourlyForecast.swift 3 | // OpenWeather 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// From https://openweathermap.org/api/one-call-api 12 | public struct HourlyForecast: Decodable { 13 | 14 | public let latitude: Double 15 | public let longitude: Double 16 | public let timezone: String 17 | public let timezoneOffset: Int 18 | public let currentWeather: WeatherData 19 | public let hourlyWeather: [WeatherData] 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case latitude = "lat" 23 | case longitude = "lon" 24 | case timezone = "timezone" 25 | case timezoneOffset = "timezone_offset" 26 | case currentWeather = "current" 27 | case hourlyWeather = "hourly" 28 | } 29 | 30 | public struct WeatherData: Decodable { 31 | 32 | public let time: Date 33 | public let sunriseTime: Date? 34 | public let sunsetTime: Date? 35 | public let temperature: Double 36 | public let temperatureFeelsLike: Double 37 | public let pressure: Double 38 | public let humidity: Double 39 | public let dewPoint: Double 40 | public let clouds: Double 41 | public let uvIndex: Double 42 | public let visibility: Double 43 | public let windSpeed: Double 44 | public let windGust: Double? 45 | public let windDirection: Double 46 | public let precipitationProbability: Double? 47 | public let rain: Volume? 48 | public let snow: Volume? 49 | public let weather: [Weather] 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case time = "dt" 53 | case sunriseTime = "sunrise" 54 | case sunsetTime = "sunset" 55 | case temperature = "temp" 56 | case temperatureFeelsLike = "feels_like" 57 | case pressure = "pressure" 58 | case humidity = "humidity" 59 | case dewPoint = "dew_point" 60 | case clouds = "clouds" 61 | case uvIndex = "uvi" 62 | case visibility = "visibility" 63 | case windSpeed = "wind_speed" 64 | case windGust = "wind_gust" 65 | case windDirection = "wind_deg" 66 | case precipitationProbability = "pop" 67 | case rain = "rain" 68 | case snow = "snow" 69 | case weather = "weather" 70 | } 71 | 72 | public struct Volume: Decodable { 73 | 74 | public let volumeLastHour: Double 75 | 76 | enum CodingKeys: String, CodingKey { 77 | case volumeLastHour = "1h" 78 | } 79 | } 80 | 81 | public struct Weather: Decodable { 82 | 83 | public let id: Int 84 | public let main: String 85 | public let description: String 86 | public let icon: String 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /OpenWeather/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /OpenWeather/Location.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Location.swift 3 | // OpenWeather 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public struct Location { 12 | 13 | let latitude: Double 14 | let longitude: Double 15 | 16 | public init(latitude: Double, longitude: Double) { 17 | self.latitude = latitude 18 | self.longitude = longitude 19 | } 20 | } 21 | 22 | 23 | extension Location: Hashable { 24 | 25 | public func hash(into hasher: inout Hasher) { 26 | hasher.combine(latitude) 27 | hasher.combine(longitude) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /OpenWeather/OpenWeather.h: -------------------------------------------------------------------------------- 1 | // 2 | // OpenWeather.h 3 | // OpenWeather 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for OpenWeather. 11 | FOUNDATION_EXPORT double OpenWeatherVersionNumber; 12 | 13 | //! Project version string for OpenWeather. 14 | FOUNDATION_EXPORT const unsigned char OpenWeatherVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Pull to Refresh 2 | ⬇️🔄 SwiftUI Pull to Refresh (for iOS 13 and iOS 14) condensed into a single modifier. 3 | 4 | Complementary repository for article [SwiftUI Pull to Refresh (for iOS 13 and iOS 14)]. With this extension you can **backport the iOS 15 refreshable modifier to iOS 13 and iOS 14**, and use the exact same code across the board. 5 | 6 | https://user-images.githubusercontent.com/1779614/160678139-6f16e4e5-2ec6-4dd6-8f79-87fcdcb05df6.mp4 7 | 8 | ```Swift 9 | struct ContentView: View { 10 | 11 | ... 12 | 13 | var body: some View { 14 | List { 15 | ... 16 | } 17 | .refreshable { 18 | await viewModel.fetch() 19 | } 20 | } 21 | } 22 | ``` 23 | 24 | Alternatively, you can opt into the **closure-based API** below to spare using async await API. 25 | 26 | ```Swift 27 | struct ContentView: View { 28 | 29 | ... 30 | 31 | var body: some View { 32 | List { 33 | ... 34 | } 35 | .onRefresh { refreshControl in 36 | viewModel.fetch { 37 | refreshControl.endRefreshing() 38 | } 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | 45 | ## Quick Start 46 | 47 | See details in `OnRefreshModifier.swift` and `RefreshableModifier.swift` in [`Refreshable`] package. Find the examples above in the [`Examples`] folder. 48 | 49 | For your own projects, simply use [`Refreshable`] Swift Package. 50 | 51 | 52 | ## License 53 | 54 | > Licensed under the [**MIT License**](https://en.wikipedia.org/wiki/MIT_License). 55 | 56 | [SwiftUI Pull to Refresh (for iOS 13 and iOS 14)]: https://blog.eppz.eu/swiftui-pull-to-refresh/ 57 | [`Refreshable`]: https://github.com/Geri-Borbas/iOS.Package.Refreshable 58 | [`Examples`]: SwiftUI_Pull_to_Refresh/Examples 59 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Refreshable", 6 | "repositoryURL": "https://github.com/Geri-Borbas/iOS.Package.Refreshable", 7 | "state": { 8 | "branch": "main", 9 | "revision": "17676f97e8158098c5a777b65fa5b07a3695a206", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Introspect", 15 | "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", 16 | "state": { 17 | "branch": "master", 18 | "revision": "72a509c93166540c0adf8323fd2652daade7f9f6", 19 | "version": null 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/xcshareddata/xcschemes/SwiftUI_Pull_to_Refresh.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 67 | 68 | 69 | 70 | 76 | 78 | 84 | 85 | 86 | 87 | 89 | 90 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/xcuserdata/eppz.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh.xcodeproj/xcuserdata/eppz.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | AlphaVantage.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | OpenWeather.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 2 16 | 17 | SwiftUI_Pull_to_Refresh.xcscheme_^#shared#^_ 18 | 19 | orderHint 20 | 0 21 | 22 | 23 | SuppressBuildableAutocreation 24 | 25 | 551C490524CC690E007C7C03 26 | 27 | primary 28 | 29 | 30 | 551C492624CC6919007C7C03 31 | 32 | primary 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 25.. 6 | // 7 | 8 | import UIKit 9 | 10 | @UIApplicationMain 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | 33 | 34 | } 35 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/AppHostingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppHostingController.swift 3 | // Sudoku 4 | // 5 | // Created by Geri Borbás on 03/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | class AppHostingController: UIHostingController { 12 | 13 | override var prefersHomeIndicatorAutoHidden: Bool { 14 | true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0", 9 | "green" : "0", 10 | "red" : "0" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Dark Gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x4D", 9 | "green" : "0x4D", 10 | "red" : "0x4D" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x4D", 27 | "green" : "0x4D", 28 | "red" : "0x4D" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Foreground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x80", 9 | "green" : "0x80", 10 | "red" : "0x80" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x80", 27 | "green" : "0x80", 28 | "red" : "0x80" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Green.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "153", 9 | "green" : "216", 10 | "red" : "84" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x99", 27 | "green" : "0xD8", 28 | "red" : "0x54" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Color/Medium Gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x66", 9 | "green" : "0x66", 10 | "red" : "0x66" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x66", 27 | "green" : "0x66", 28 | "red" : "0x66" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Opaque World Map with Blur.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Opaque World Map with Blur@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Opaque World Map with Blur@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur@2x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map with Blur.imageset/Opaque World Map with Blur@3x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Opaque World Map.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Opaque World Map@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Opaque World Map@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map@2x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Opaque World Map.imageset/Opaque World Map@3x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Square.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Square@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Square@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square@2x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/10pt.imageset/Square@3x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Map.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Map@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Map@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map@2x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/Prototypes/Map.imageset/Map@3x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "WorldMap.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "WorldMap@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "WorldMap@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap@2x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Assets.xcassets/Image/WorldMap.imageset/WorldMap@3x.png -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/Extensions/UIResponder+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIResponder+Extensions.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | 12 | extension UIResponder { 13 | 14 | var parentViewController: UIViewController? { 15 | next as? UIViewController ?? next?.parentViewController 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/Extensions/UIView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Extensions.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/09/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | 12 | extension UIView { 13 | 14 | /// Returs frame in screen coordinates. 15 | var globalFrame: CGRect { 16 | if let window = window { 17 | return convert(bounds, to: window.screen.coordinateSpace) 18 | } else { 19 | return .zero 20 | } 21 | } 22 | 23 | /// Returns with all the instances of the given view type in the view hierarchy. 24 | func viewsInHierarchy() -> [ViewType]? { 25 | var views: [ViewType] = [] 26 | viewsInHierarchy(views: &views) 27 | return views.count > 0 ? views : nil 28 | } 29 | 30 | fileprivate func viewsInHierarchy(views: inout [ViewType]) { 31 | subviews.forEach { eachSubView in 32 | if let matchingView = eachSubView as? ViewType { 33 | views.append(matchingView) 34 | } 35 | eachSubView.viewsInHierarchy(views: &views) 36 | } 37 | } 38 | 39 | /// Search ancestral view hierarcy for the given view type. 40 | func searchViewAnchestorsFor( 41 | _ onViewFound: (ViewType) -> Void 42 | ) { 43 | if let matchingView = self.superview as? ViewType { 44 | onViewFound(matchingView) 45 | } else { 46 | superview?.searchViewAnchestorsFor(onViewFound) 47 | } 48 | } 49 | 50 | /// Search ancestral view hierarcy for the given view type. 51 | func searchViewAnchestorsFor() -> ViewType? { 52 | if let matchingView = self.superview as? ViewType { 53 | return matchingView 54 | } else { 55 | return superview?.searchViewAnchestorsFor() 56 | } 57 | } 58 | 59 | func printViewHierarchyInformation(_ level: Int = 0) { 60 | printViewInformation(level) 61 | self.subviews.forEach { $0.printViewHierarchyInformation(level + 1) } 62 | } 63 | 64 | func printViewInformation(_ level: Int) { 65 | let leadingWhitespace = String(repeating: " ", count: level) 66 | let className = "\(Self.self)" 67 | let superclassName = "\(self.superclass!)" 68 | let frame = "\(self.frame)" 69 | let id = (self.accessibilityIdentifier == nil) ? "" : " `\(self.accessibilityIdentifier!)`" 70 | print("\(leadingWhitespace)\(className): \(superclassName)\(id) \(frame)") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/OnScroll.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnScroll.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | class ScrollViewDelegate: NSObject, UIScrollViewDelegate { 13 | 14 | let onScroll: ((_ scrollView: UIScrollView) -> Void) 15 | 16 | init(onScroll: @escaping ((_ scrollView: UIScrollView) -> Void)) { 17 | self.onScroll = onScroll 18 | } 19 | 20 | @objc func scrollViewDidScroll(_ scrollView: UIScrollView) { 21 | onScroll(scrollView) 22 | } 23 | } 24 | 25 | private struct OnScrollModifier: ViewModifier { 26 | 27 | @State var geometryReaderFrame: CGRect = .zero 28 | let scrollViewDelegate: ScrollViewDelegate 29 | 30 | init(onScroll: @escaping ((_ scrollView: UIScrollView) -> Void)) { 31 | self.scrollViewDelegate = ScrollViewDelegate(onScroll: onScroll) 32 | } 33 | 34 | func body(content: Content) -> some View { 35 | content 36 | .background( 37 | GeometryReader { geometry in 38 | ScrollViewMatcher( 39 | onResolve: { scrollView in 40 | scrollView.delegate = scrollViewDelegate 41 | }, 42 | geometryReaderFrame: $geometryReaderFrame 43 | ) 44 | .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global)) 45 | .onPreferenceChange(FramePreferenceKey.self) { frame in 46 | self.geometryReaderFrame = frame 47 | } 48 | } 49 | ) 50 | } 51 | } 52 | 53 | 54 | extension View { 55 | 56 | func onScroll(onScroll: @escaping ((_ scrollView: UIScrollView) -> Void)) -> some View { 57 | self.modifier(OnScrollModifier(onScroll: onScroll)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/PagingModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PagingModifier.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 20/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | private struct PagingModifier: ViewModifier { 13 | 14 | @State var geometryReaderFrame: CGRect = .zero 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .background( 19 | GeometryReader { geometry in 20 | ScrollViewMatcher( 21 | onResolve: { scrollView in 22 | scrollView.isPagingEnabled = true 23 | }, 24 | geometryReaderFrame: $geometryReaderFrame 25 | ) 26 | .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global)) 27 | .onPreferenceChange(FramePreferenceKey.self) { frame in 28 | self.geometryReaderFrame = frame 29 | } 30 | } 31 | ) 32 | } 33 | } 34 | 35 | 36 | extension View { 37 | 38 | func paging() -> some View { 39 | self.modifier(PagingModifier()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/RefreshControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControl.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/09/2021. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import Combine 11 | 12 | 13 | class RefreshControl: ObservableObject { 14 | 15 | let onValueChanged: ((_ refreshControl: UIRefreshControl) -> Void) 16 | 17 | internal init(onValueChanged: @escaping ((UIRefreshControl) -> Void)) { 18 | self.onValueChanged = onValueChanged 19 | } 20 | 21 | /// Adds a `UIRefreshControl` to the `UIScrollView` provided. 22 | func add(to scrollView: UIScrollView) { 23 | scrollView.refreshControl = UIRefreshControl().withTarget( 24 | self, 25 | action: #selector(self.onValueChangedAction), 26 | for: .valueChanged 27 | ).testable(as: "RefreshControl") 28 | } 29 | 30 | @objc private func onValueChangedAction(sender: UIRefreshControl) { 31 | self.onValueChanged(sender) 32 | } 33 | } 34 | 35 | 36 | extension UIRefreshControl { 37 | 38 | /// Convinience method to assign target action inline. 39 | func withTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) -> UIRefreshControl { 40 | self.addTarget(target, action: action, for: controlEvents) 41 | return self 42 | } 43 | 44 | /// Convinience method to match refresh control for UI testing. 45 | func testable(as id: String) -> UIRefreshControl { 46 | self.isAccessibilityElement = true 47 | self.accessibilityIdentifier = id 48 | return self 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/RefreshControlModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControlModifier.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | struct RefreshControlModifier: ViewModifier { 13 | 14 | @State var geometryReaderFrame: CGRect = .zero 15 | let refreshControl: RefreshControl 16 | 17 | internal init(onValueChanged: @escaping (UIRefreshControl) -> Void) { 18 | self.refreshControl = RefreshControl(onValueChanged: onValueChanged) 19 | } 20 | 21 | func body(content: Content) -> some View { 22 | content 23 | .background( 24 | GeometryReader { geometry in 25 | ScrollViewMatcher( 26 | onResolve: { scrollView in 27 | refreshControl.add(to: scrollView) 28 | }, 29 | geometryReaderFrame: $geometryReaderFrame 30 | ) 31 | .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global)) 32 | .onPreferenceChange(FramePreferenceKey.self) { frame in 33 | self.geometryReaderFrame = frame 34 | } 35 | } 36 | ) 37 | } 38 | } 39 | 40 | 41 | extension View { 42 | 43 | func refreshControl(onValueChanged: @escaping (_ refreshControl: UIRefreshControl) -> Void) -> some View { 44 | self.modifier(RefreshControlModifier(onValueChanged: onValueChanged)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/ScrollViewMatcher/FramePreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FramePreferenceKey.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | struct FramePreferenceKey: PreferenceKey { 13 | 14 | typealias Value = CGRect 15 | static var defaultValue = CGRect.zero 16 | 17 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) { 18 | value = nextValue() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/ScrollViewMatcher/ScrollViewMatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewMatcher.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 17/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | final class ScrollViewMatcher: UIViewControllerRepresentable { 13 | 14 | let onMatch: (UIScrollView) -> Void 15 | @Binding var geometryReaderFrame: CGRect 16 | 17 | init(onResolve: @escaping (UIScrollView) -> Void, geometryReaderFrame: Binding) { 18 | self.onMatch = onResolve 19 | self._geometryReaderFrame = geometryReaderFrame 20 | } 21 | 22 | func makeUIViewController(context: Context) -> ScrollViewMatcherViewController { 23 | ScrollViewMatcherViewController(onResolve: onMatch, geometryReaderFrame: geometryReaderFrame) 24 | } 25 | 26 | func updateUIViewController(_ viewController: ScrollViewMatcherViewController, context: Context) { 27 | viewController.geometryReaderFrame = geometryReaderFrame 28 | } 29 | } 30 | 31 | class ScrollViewMatcherViewController: UIViewController { 32 | 33 | let onMatch: (UIScrollView) -> Void 34 | private var scrollView: UIScrollView? { 35 | didSet { 36 | if oldValue != scrollView, 37 | let scrollView = scrollView { 38 | onMatch(scrollView) 39 | } 40 | } 41 | } 42 | 43 | var geometryReaderFrame: CGRect { 44 | didSet { 45 | match() 46 | } 47 | } 48 | 49 | init(onResolve: @escaping (UIScrollView) -> Void, geometryReaderFrame: CGRect, debug: Bool = false) { 50 | self.onMatch = onResolve 51 | self.geometryReaderFrame = geometryReaderFrame 52 | super.init(nibName: nil, bundle: nil) 53 | } 54 | 55 | required init?(coder: NSCoder) { 56 | fatalError("Use init(onMatch:) to instantiate ScrollViewMatcherViewController.") 57 | } 58 | 59 | func match() { 60 | // matchUsingHierarchy() 61 | matchUsingGeometry() 62 | } 63 | 64 | func matchUsingHierarchy() { 65 | if parent != nil { 66 | 67 | // Lookup view ancestry for any `UIScrollView`. 68 | view.searchViewAnchestorsFor { (scrollView: UIScrollView) in 69 | self.scrollView = scrollView 70 | } 71 | } 72 | } 73 | 74 | func matchUsingGeometry() { 75 | if let parent = parent { 76 | if let scrollViewsInHierarchy: [UIScrollView] = parent.view.viewsInHierarchy() { 77 | 78 | // Return first match if only a single scroll view is found in the hierarchy. 79 | if scrollViewsInHierarchy.count == 1, 80 | let firstScrollViewInHierarchy = scrollViewsInHierarchy.first { 81 | self.scrollView = firstScrollViewInHierarchy 82 | 83 | // Filter by frame origins if multiple matches found. 84 | } else { 85 | if let firstMatchingFrameOrigin = scrollViewsInHierarchy.filter({ 86 | $0.globalFrame.origin.close(to: geometryReaderFrame.origin) 87 | }).first { 88 | self.scrollView = firstMatchingFrameOrigin 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | override func didMove(toParent parent: UIViewController?) { 96 | super.didMove(toParent: parent) 97 | match() 98 | } 99 | } 100 | 101 | fileprivate extension CGPoint { 102 | 103 | /// Returns `true` if this point is close the other point (considering a ~1 pt tolerance). 104 | func close(to point: CGPoint) -> Bool { 105 | let inset = CGFloat(1) 106 | let rect = CGRect(x: x - inset, y: y - inset, width: inset * 2, height: inset * 2) 107 | return rect.contains(point) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/ScrollViewResolver/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 25.. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | 12 | fileprivate class RefreshControl: ObservableObject { 13 | 14 | var onValueChanged: (() -> Void)? 15 | @Published /* private(set) */ var isRefreshing: Bool = false 16 | 17 | private weak var refreshControl: UIRefreshControl? 18 | private var subscribers: Set = [] 19 | 20 | func add(onValueChanged: @escaping () -> Void) { 21 | self.onValueChanged = onValueChanged 22 | } 23 | 24 | init() { 25 | $isRefreshing.removeDuplicates().sink { (isRefreshing: Bool) in 26 | if isRefreshing == false { 27 | self.refreshControl?.endRefreshing() 28 | } 29 | }.store(in: &subscribers) 30 | } 31 | 32 | /// Adds (and stores) a `UIRefreshControl` to the `UIScrollView` provided. 33 | func add(to scrollView: UIScrollView) { 34 | print("RefreshControl.\(#function)") 35 | 36 | // Only if not added already. 37 | guard self.refreshControl == nil else { return } 38 | 39 | // Create then add to scroll view. 40 | scrollView.refreshControl = UIRefreshControl().withTarget( 41 | self, 42 | action: #selector(self.onValueChangedAction), 43 | for: .valueChanged 44 | ).testable(as: "RefreshControl") 45 | 46 | // Reference (weak). 47 | self.refreshControl = scrollView.refreshControl 48 | } 49 | 50 | @objc private func onValueChangedAction() { 51 | print("RefreshControl.\(#function)") 52 | self.isRefreshing = true 53 | self.onValueChanged?() 54 | } 55 | 56 | deinit { 57 | print("RefreshControlCoordinator.\(#function)") 58 | subscribers.removeAll() 59 | } 60 | } 61 | 62 | 63 | struct ContentView: View { 64 | 65 | @ObservedObject fileprivate var refreshControl_1: RefreshControl = RefreshControl() 66 | @ObservedObject fileprivate var refreshControl_2: RefreshControl = RefreshControl() 67 | 68 | @ViewBuilder 69 | var body: some View { 70 | 71 | List { 72 | ScrollViewResolver(onResolve: { _ in 73 | // self.refreshControl.add(to: scrollView) 74 | // self.refreshControl.add(onValueChanged: onValueChanged) 75 | }) 76 | ForEach(1...100, id: \.self) { eachRowIndex in 77 | Text("Row \(eachRowIndex)") 78 | } 79 | .opacity(self.refreshControl_1.isRefreshing ? 0.2 : 1.0) 80 | } 81 | 82 | List { 83 | RefreshControlInjector( 84 | refreshControl: self.refreshControl_2, 85 | onValueChanged: { 86 | self.refresh_2() 87 | }) 88 | ForEach(1...100, id: \.self) { eachRowIndex in 89 | Text("Row \(eachRowIndex)") 90 | } 91 | .opacity(self.refreshControl_2.isRefreshing ? 0.2 : 1.0) 92 | } 93 | } 94 | 95 | func refresh_1() { 96 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 97 | self.refreshControl_1.isRefreshing = false 98 | } 99 | } 100 | 101 | func refresh_2() { 102 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 103 | self.refreshControl_2.isRefreshing = false 104 | } 105 | } 106 | } 107 | 108 | 109 | struct RefreshControlInjector: View { 110 | 111 | fileprivate var refreshControl: RefreshControl 112 | let onValueChanged: () -> Void 113 | 114 | var body: some View { 115 | ScrollViewResolver(onResolve: { (scrollView: UIScrollView) in 116 | self.refreshControl.add(to: scrollView) 117 | self.refreshControl.add(onValueChanged: onValueChanged) 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Deprecated/ScrollViewResolver/RefreshControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControl.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 26.. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | 12 | struct ScrollViewResolver: UIViewRepresentable { 13 | 14 | let onResolve: (UIScrollView) -> Void 15 | 16 | func makeCoordinator() -> ScrollViewResolverCoordinator { 17 | ScrollViewResolverCoordinator() 18 | } 19 | 20 | func makeUIView(context: Context) -> UIView { 21 | print("RefreshControl.\(#function)") 22 | let view = UIView(frame: .zero) 23 | view.isHidden = true 24 | view.isUserInteractionEnabled = false 25 | return view 26 | } 27 | 28 | func updateUIView(_ view: UIView, context: Context) { 29 | 30 | // Only if not resolved yet. 31 | guard context.coordinator.scrollView == nil else { return } 32 | 33 | // Lookup view ancestry for any `UIScrollView`. 34 | if let scrollView = view.searchViewAnchestors(for: UIScrollView.self) { 35 | print("🎉") 36 | self.onResolve(scrollView) 37 | context.coordinator.scrollView = scrollView 38 | } 39 | } 40 | } 41 | 42 | class ScrollViewResolverCoordinator: NSObject { 43 | 44 | var scrollView: UIScrollView? 45 | } 46 | 47 | 48 | // MARK: - Extensions 49 | 50 | extension UIRefreshControl { 51 | 52 | /// Convinience method to assign target action inline. 53 | func withTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) -> UIRefreshControl { 54 | self.addTarget(target, action: action, for: controlEvents) 55 | return self 56 | } 57 | 58 | /// Convinience method to assign target action inline. 59 | func testable(as id: String) -> UIRefreshControl { 60 | self.isAccessibilityElement = true 61 | self.accessibilityIdentifier = id 62 | return self 63 | } 64 | } 65 | 66 | 67 | fileprivate extension UIView { 68 | 69 | /// Search ancestral view hierarcy for the given view type. 70 | func searchViewAnchestors(for viewType: ViewType.Type) -> ViewType? { 71 | if let matchingView = self.superview as? ViewType { 72 | return matchingView 73 | } else { 74 | return superview?.searchViewAnchestors(for: viewType) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Examples/AsyncAwaitModifierView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncAwaitModifierView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 15/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct AsyncAwaitModifierView: View { 12 | 13 | @ObservedObject var viewModel = ViewModel() 14 | 15 | var body: some View { 16 | List { 17 | ForEach(1...100, id: \.self) { eachRowIndex in 18 | Text("Row \(eachRowIndex)") 19 | } 20 | } 21 | .refreshable { 22 | await viewModel.fetch() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Examples/ClosureBasedModifierView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosureBasedModifierView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Refreshable 10 | 11 | 12 | struct ClosureBasedModifierView: View { 13 | 14 | @ObservedObject var viewModel = ViewModel() 15 | 16 | var body: some View { 17 | List { 18 | ForEach(1...100, id: \.self) { eachRowIndex in 19 | Text("Row \(eachRowIndex)") 20 | } 21 | } 22 | .onRefresh { refreshControl in 23 | viewModel.fetch { 24 | refreshControl.endRefreshing() 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Extensions/UIScrollView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScrollView+Extensions.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 23/03/2022. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | 12 | extension UIScrollView { 13 | 14 | static var speed = UI.Speed() 15 | 16 | func set(speed: UI.Speed) { 17 | 18 | // Only if tweaked. 19 | guard speed.layerSpeed != 1.0 else { 20 | return 21 | } 22 | 23 | // Save. 24 | Self.speed = speed 25 | 26 | // Scroll deceleration. 27 | // self.decelerationRate = .init(rawValue: speed.decelerationRate) 28 | // self.setValue(speed.pagingFriction, forKey: "pagingFriction") 29 | // self.setValue(speed.decelerationRate, forKey: "verticalScrollDecelerationFactor") 30 | // self.setValue(speed.decelerationRate, forKey: "horizontalScrollDecelerationFactor") 31 | 32 | // Bounce deceleration(s). 33 | swizzleGetBouncingDecelerationOffset() 34 | swizzleGetPagingDecelerationOffset() 35 | swizzleGetStandardDecelerationOffset() 36 | 37 | // Animation duration after refreshing. 38 | self.setValue(speed.contentOffsetAnimationDuration, forKey: "contentOffsetAnimationDuration") 39 | } 40 | } 41 | 42 | 43 | // MARK: Bouncing speed 44 | 45 | extension UIScrollView { 46 | 47 | func swizzleGetBouncingDecelerationOffset() { 48 | 49 | guard let scrollViewClass: AnyClass = object_getClass(self) else { 50 | return print("Could not get `UIScrollView` class.") 51 | } 52 | 53 | let selectorName = "_getBouncingDecelerationOffset:forTimeInterval:lastUpdateOffset:min:max:decelerationFactor:decelerationLnFactor:velocity:" 54 | let selector = Selector(selectorName) 55 | guard let method = class_getInstanceMethod(scrollViewClass, selector) else { 56 | return print("Could not get `getBouncingDecelerationOffset()` selector.") 57 | } 58 | 59 | let originalSelector = #selector(originalGetBouncingDecelerationOffset(_:forTimeInterval:lastUpdateOffset:min:max:decelerationFactor:decelerationLnFactor:velocity:)) 60 | guard let originalMethod = class_getInstanceMethod(scrollViewClass, originalSelector) else { 61 | return print("Could not get original `getBouncingDecelerationOffset()` selector.") 62 | } 63 | 64 | let swizzledSelector = #selector(swizzledGetBouncingDecelerationOffset(_:forTimeInterval:lastUpdateOffset:min:max:decelerationFactor:decelerationLnFactor:velocity:)) 65 | guard let swizzledMethod = class_getInstanceMethod(scrollViewClass, swizzledSelector) else { 66 | return print("Could not get swizzled `getBouncingDecelerationOffset()` selector.") 67 | } 68 | 69 | // Swap implementations. 70 | method_exchangeImplementations(method, originalMethod) 71 | method_exchangeImplementations(method, swizzledMethod) 72 | } 73 | 74 | // - (BOOL)_getBouncingDecelerationOffset:(double*)arg1 75 | // forTimeInterval:(double)arg2 76 | // lastUpdateOffset:(double)arg3 77 | // min:(double)arg4 78 | // max:(double)arg5 79 | // decelerationFactor:(double)arg6 80 | // decelerationLnFactor:(double)arg7 81 | // velocity:(double*)arg8; 82 | 83 | @objc func originalGetBouncingDecelerationOffset( 84 | _ bouncingDecelerationOffset: UnsafeMutablePointer, 85 | forTimeInterval timeInterval: TimeInterval, 86 | lastUpdateOffset: TimeInterval, 87 | min: CGFloat, 88 | max: CGFloat, 89 | decelerationFactor: CGFloat, 90 | decelerationLnFactor: CGFloat, 91 | velocity: UnsafeMutablePointer 92 | ) -> Bool { 93 | return true // Original implementation will be copied here 94 | } 95 | 96 | @objc func swizzledGetBouncingDecelerationOffset( 97 | _ bouncingDecelerationOffset: UnsafeMutablePointer, 98 | forTimeInterval timeInterval: TimeInterval, 99 | lastUpdateOffset: TimeInterval, 100 | min: CGFloat, 101 | max: CGFloat, 102 | decelerationFactor: CGFloat, 103 | decelerationLnFactor: CGFloat, 104 | velocity: UnsafeMutablePointer 105 | ) -> Bool { 106 | return originalGetBouncingDecelerationOffset( 107 | bouncingDecelerationOffset, 108 | forTimeInterval: timeInterval * CGFloat(Self.speed.layerSpeed), // ✨ 109 | lastUpdateOffset: lastUpdateOffset, 110 | min: min, 111 | max: max, 112 | decelerationFactor: decelerationFactor, 113 | decelerationLnFactor: decelerationLnFactor, 114 | velocity: velocity 115 | ) 116 | } 117 | } 118 | 119 | 120 | // MARK: Standard speed 121 | 122 | extension UIScrollView { 123 | 124 | func swizzleGetStandardDecelerationOffset() { 125 | 126 | guard let scrollViewClass: AnyClass = object_getClass(self) else { 127 | return print("Could not get `UIScrollView` class.") 128 | } 129 | 130 | let selectorName = "_getStandardDecelerationOffset:forTimeInterval:min:max:decelerationFactor:decelerationLnFactor:velocity:" 131 | let selector = Selector(selectorName) 132 | guard let method = class_getInstanceMethod(scrollViewClass, selector) else { 133 | return print("Could not get `getStandardDecelerationOffset()` selector.") 134 | } 135 | 136 | let originalSelector = #selector(originalGetStandardDecelerationOffset(_:forTimeInterval:min:max:decelerationFactor:decelerationLnFactor:velocity:)) 137 | guard let originalMethod = class_getInstanceMethod(scrollViewClass, originalSelector) else { 138 | return print("Could not get original `getStandardDecelerationOffset()` selector.") 139 | } 140 | 141 | let swizzledSelector = #selector(swizzledGetStandardDecelerationOffset(_:forTimeInterval:min:max:decelerationFactor:decelerationLnFactor:velocity:)) 142 | guard let swizzledMethod = class_getInstanceMethod(scrollViewClass, swizzledSelector) else { 143 | return print("Could not get swizzled `getStandardDecelerationOffset()` selector.") 144 | } 145 | 146 | // Swap implementations. 147 | method_exchangeImplementations(method, originalMethod) 148 | method_exchangeImplementations(method, swizzledMethod) 149 | } 150 | 151 | // - (void)_getStandardDecelerationOffset:(double*)arg1 152 | // forTimeInterval:(double)arg2 153 | // min:(double)arg3 154 | // max:(double)arg4 155 | // decelerationFactor:(double)arg5 156 | // decelerationLnFactor:(double)arg6 157 | // velocity:(double*)arg7; 158 | 159 | @objc func originalGetStandardDecelerationOffset( 160 | _ standardDecelerationOffset: UnsafeMutablePointer, 161 | forTimeInterval timeInterval: TimeInterval, 162 | min: CGFloat, 163 | max: CGFloat, 164 | decelerationFactor: CGFloat, 165 | decelerationLnFactor: CGFloat, 166 | velocity: UnsafeMutablePointer 167 | ) -> Bool { 168 | return true // Original implementation will be copied here 169 | } 170 | 171 | @objc func swizzledGetStandardDecelerationOffset( 172 | _ standardDecelerationOffset: UnsafeMutablePointer, 173 | forTimeInterval timeInterval: TimeInterval, 174 | min: CGFloat, 175 | max: CGFloat, 176 | decelerationFactor: CGFloat, 177 | decelerationLnFactor: CGFloat, 178 | velocity: UnsafeMutablePointer 179 | ) -> Bool { 180 | return originalGetStandardDecelerationOffset( 181 | standardDecelerationOffset, 182 | forTimeInterval: timeInterval * CGFloat(Self.speed.layerSpeed), // ✨ 183 | min: min, 184 | max: max, 185 | decelerationFactor: decelerationFactor, 186 | decelerationLnFactor: decelerationLnFactor, 187 | velocity: velocity 188 | ) 189 | } 190 | } 191 | 192 | 193 | // MARK: Paging speed 194 | 195 | extension UIScrollView { 196 | 197 | func swizzleGetPagingDecelerationOffset() { 198 | 199 | guard let scrollViewClass: AnyClass = object_getClass(self) else { 200 | return print("Could not get `UIScrollView` class.") 201 | } 202 | 203 | let selectorName = "_getPagingDecelerationOffset:forTimeInterval:" 204 | let selector = Selector(selectorName) 205 | guard let method = class_getInstanceMethod(scrollViewClass, selector) else { 206 | return print("Could not get `getPagingDecelerationOffset()` selector.") 207 | } 208 | 209 | let originalSelector = #selector(originalGetPagingDecelerationOffset(_:forTimeInterval:)) 210 | guard let originalMethod = class_getInstanceMethod(scrollViewClass, originalSelector) else { 211 | return print("Could not get original `getPagingDecelerationOffset()` selector.") 212 | } 213 | 214 | let swizzledSelector = #selector(swizzledGetPagingDecelerationOffset(_:forTimeInterval:)) 215 | guard let swizzledMethod = class_getInstanceMethod(scrollViewClass, swizzledSelector) else { 216 | return print("Could not get swizzled `getPagingDecelerationOffset()` selector.") 217 | } 218 | 219 | // Swap implementations. 220 | method_exchangeImplementations(method, originalMethod) 221 | method_exchangeImplementations(method, swizzledMethod) 222 | } 223 | 224 | // - (bool)_getPagingDecelerationOffset:(struct CGPoint { double x1; double x2; }*)arg1 225 | // forTimeInterval:(double)arg2; 226 | 227 | @objc func originalGetPagingDecelerationOffset( 228 | _ standardDecelerationOffset: UnsafeMutablePointer, 229 | forTimeInterval timeInterval: TimeInterval 230 | ) -> Bool { 231 | return true // Original implementation will be copied here 232 | } 233 | 234 | @objc func swizzledGetPagingDecelerationOffset( 235 | _ standardDecelerationOffset: UnsafeMutablePointer, 236 | forTimeInterval timeInterval: TimeInterval 237 | ) -> Bool { 238 | return originalGetPagingDecelerationOffset( 239 | standardDecelerationOffset, 240 | forTimeInterval: timeInterval * CGFloat(Self.speed.layerSpeed) // ✨ 241 | ) 242 | } 243 | } 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Extensions/Withable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Withable.swift 3 | // Declarative_UIKit 4 | // 5 | // Created by Geri Borbás on 28/11/2020. 6 | // http://www.twitter.com/Geri_Borbas 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | // MARK: - Withable for Objects 13 | 14 | protocol ObjectWithable: AnyObject { 15 | 16 | associatedtype T 17 | 18 | /// Provides a closure to configure instances inline. 19 | /// - Parameter closure: A closure `self` as the argument. 20 | /// - Returns: Simply returns the instance after called the `closure`. 21 | @discardableResult func with(_ closure: (_ instance: T) -> Void) -> T 22 | } 23 | 24 | extension ObjectWithable { 25 | 26 | @discardableResult func with(_ closure: (_ instance: Self) -> Void) -> Self { 27 | closure(self) 28 | return self 29 | } 30 | } 31 | 32 | extension NSObject: ObjectWithable { } 33 | 34 | 35 | // MARK: - Withable for Values 36 | 37 | protocol Withable { 38 | 39 | associatedtype T 40 | 41 | /// Provides a closure to configure instances inline. 42 | /// - Parameter closure: A closure with a mutable copy of `self` as the argument. 43 | /// - Returns: Simply returns the mutated copy of the instance after called the `closure`. 44 | @discardableResult func with(_ closure: (_ instance: inout T) -> Void) -> T 45 | } 46 | 47 | extension Withable { 48 | 49 | @discardableResult func with(_ closure: (_ instance: inout Self) -> Void) -> Self { 50 | var copy = self 51 | closure(©) 52 | return copy 53 | } 54 | } 55 | 56 | 57 | // MARK: - Examples 58 | 59 | struct Point: Withable { 60 | var x: Int = 0 61 | var y: Int = 0 62 | } 63 | 64 | extension PersonNameComponents: Withable { } 65 | 66 | struct Test { 67 | 68 | let formatter = DateFormatter().with { 69 | $0.dateStyle = .medium 70 | } 71 | 72 | let point = Point().with { 73 | $0.x = 10 74 | $0.y = 10 75 | } 76 | 77 | let name = PersonNameComponents().with { 78 | $0.givenName = "Geri" 79 | $0.familyName = "Borbás" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UIApplicationSupportsIndirectInputEvents 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | UIAppFonts 62 | 63 | Lato-Thin.ttf 64 | Lato-Light.ttf 65 | Lato-Regular.ttf 66 | Lato-Bold.ttf 67 | Lato-Black.ttf 68 | 69 | UIUserInterfaceStyle 70 | Dark 71 | 72 | 73 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/Lato-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Lato/Lato-Black.ttf -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/Lato-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Lato/Lato-Bold.ttf -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/Lato-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Lato/Lato-Light.ttf -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Lato/Lato-Regular.ttf -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/Lato-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Geri-Borbas/iOS.Blog.SwiftUI_Pull_to_Refresh/2bb07631d6ca8cc5cf96d779e8186b2749603bf1/SwiftUI_Pull_to_Refresh/Lato/Lato-Thin.ttf -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Lato/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/IntrospectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntrospectView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 12/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | struct IntrospectView: View { 13 | 14 | @ObservedObject var viewModel = ViewModel() 15 | 16 | let size = CGSize(width: 375, height: 800) 17 | let colors: [UIColor] = [ 18 | .red, 19 | .orange, 20 | .yellow, 21 | .green, 22 | .cyan, 23 | .blue, 24 | .purple, 25 | .red, 26 | .orange, 27 | .yellow, 28 | .green, 29 | .cyan, 30 | .blue, 31 | .purple, 32 | .red, 33 | .orange, 34 | .yellow, 35 | .green, 36 | .cyan, 37 | .blue, 38 | .purple, 39 | .red, 40 | .orange, 41 | .yellow 42 | ] 43 | 44 | var body: some View { 45 | ScrollView(.horizontal) { 46 | HStack(spacing: 0) { 47 | ForEach(0..<20) { horizontalIndex in 48 | VStack { 49 | Text("Header \(horizontalIndex + 1)") 50 | List(0..<20) { verticalIndex in 51 | Text("\(horizontalIndex + 1) : \(verticalIndex + 1)") 52 | } 53 | .padding(.vertical) 54 | .onRefresh { refreshControl in 55 | refreshControl.attributedTitle = NSAttributedString(string: "List \(horizontalIndex + 1)") 56 | viewModel.fetch { 57 | refreshControl.endRefreshing() 58 | } 59 | } 60 | } 61 | .background(Color.gray) 62 | .padding(.vertical) 63 | .frame(width: size.width, height: size.height) 64 | } 65 | 66 | } 67 | } 68 | .introspectScrollView { scrollView in 69 | scrollView.isPagingEnabled = true 70 | scrollView.backgroundColor = .black 71 | } 72 | .padding(.vertical) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/RefreshControlClosureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControlClosureView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | fileprivate class RefreshControlTarget: ObservableObject { 13 | 14 | private var onValueChanged: ((_ refreshControl: UIRefreshControl) -> Void)? 15 | 16 | func use(for scrollView: UIScrollView, onValueChanged: @escaping ((UIRefreshControl) -> Void)) { 17 | let refreshControl = UIRefreshControl() 18 | refreshControl.addTarget( 19 | self, 20 | action: #selector(self.onValueChangedAction), 21 | for: .valueChanged 22 | ) 23 | scrollView.refreshControl = refreshControl 24 | self.onValueChanged = onValueChanged 25 | } 26 | 27 | @objc private func onValueChangedAction(sender: UIRefreshControl) { 28 | self.onValueChanged?(sender) 29 | } 30 | } 31 | 32 | 33 | struct RefreshControlClosureView: View { 34 | 35 | @ObservedObject fileprivate var target = RefreshControlTarget() 36 | 37 | var body: some View { 38 | List { 39 | ForEach(1...100, id: \.self) { eachRowIndex in 40 | Text("Row \(eachRowIndex)") 41 | } 42 | } 43 | .introspectTableView { tableView in 44 | target.use(for: tableView) { refreshControl in 45 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 46 | refreshControl.endRefreshing() 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/RefreshControlExtensionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControlExtensionView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | fileprivate class RefreshControlTarget: ObservableObject { 13 | 14 | private var onValueChanged: ((_ refreshControl: UIRefreshControl) -> Void)? 15 | 16 | func use(for scrollView: UIScrollView, onValueChanged: @escaping ((UIRefreshControl) -> Void)) { 17 | let refreshControl = UIRefreshControl() 18 | refreshControl.addTarget( 19 | self, 20 | action: #selector(self.onValueChangedAction), 21 | for: .valueChanged 22 | ) 23 | scrollView.refreshControl = refreshControl 24 | self.onValueChanged = onValueChanged 25 | } 26 | 27 | @objc private func onValueChangedAction(sender: UIRefreshControl) { 28 | self.onValueChanged?(sender) 29 | } 30 | } 31 | 32 | 33 | fileprivate extension UIScrollView { 34 | 35 | struct Keys { 36 | static var target: UInt8 = 0 37 | } 38 | 39 | var target: RefreshControlTarget? { 40 | get { 41 | objc_getAssociatedObject(self, &Keys.target) as? RefreshControlTarget 42 | } 43 | set { 44 | objc_setAssociatedObject(self, &Keys.target, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 45 | } 46 | } 47 | } 48 | 49 | 50 | struct RefreshControlExtensionView: View { 51 | 52 | @ObservedObject var viewModel = ViewModel() 53 | 54 | var body: some View { 55 | List { 56 | ForEach(1...100, id: \.self) { eachRowIndex in 57 | Text("Row \(eachRowIndex)") 58 | } 59 | } 60 | .introspectTableView { tableView in 61 | tableView.target = RefreshControlTarget() 62 | tableView.target?.use(for: tableView) { refreshControl in 63 | viewModel.fetch { 64 | refreshControl.endRefreshing() 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/RefreshControlTargetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControlTargetView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | fileprivate class RefreshControlTarget: ObservableObject { 13 | 14 | @objc func onValueChangedAction(sender: UIRefreshControl) { 15 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 16 | sender.endRefreshing() 17 | } 18 | } 19 | } 20 | 21 | 22 | struct RefreshControlTargetView: View { 23 | 24 | @ObservedObject fileprivate var target = RefreshControlTarget() 25 | 26 | var body: some View { 27 | List { 28 | ForEach(1...100, id: \.self) { eachRowIndex in 29 | Text("Row \(eachRowIndex)") 30 | } 31 | } 32 | .introspectTableView { tableView in 33 | tableView.refreshControl = UIRefreshControl() 34 | tableView.refreshControl?.addTarget( 35 | target, 36 | action: #selector(RefreshControlTarget.onValueChangedAction), 37 | for: .valueChanged 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/ScrollViewExtensionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewExtensionView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | import Refreshable 11 | 12 | 13 | struct ScrollViewExtensionView: View { 14 | 15 | @ObservedObject var viewModel = ViewModel() 16 | 17 | var body: some View { 18 | List { 19 | ForEach(1...100, id: \.self) { eachRowIndex in 20 | Text("Row \(eachRowIndex)") 21 | } 22 | } 23 | .introspectTableView { tableView in 24 | tableView.onRefresh { refreshControl in 25 | viewModel.fetch { 26 | refreshControl.endRefreshing() 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/Sketches/FitTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FitTextView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct FitTextView: View { 12 | 13 | var body: some View { 14 | ZStack { 15 | Rectangle() 16 | .fill(ImagePaint(image: Image("10pt"))) 17 | .opacity(0.2) 18 | VStack { 19 | TitleView.mock 20 | List { 21 | Section( 22 | header: SummaryView.mock 23 | .listRowInsets(.zero), 24 | content: { 25 | ForEach(1...20, id: \.self) { eachRowIndex in 26 | Text("Row \(eachRowIndex)") 27 | .listRowBackground( 28 | Color.clear 29 | .redLine(opacity: 0.5) 30 | ) 31 | } 32 | } 33 | ) 34 | } 35 | .listStyle(.plain) 36 | .redLine(opacity: 0.5) 37 | } 38 | .padding(.horizontal, UI.padding) 39 | .redLine(opacity: 0.5) 40 | } 41 | .preferredColorScheme(.dark) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/Sketches/HeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaderView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct HeaderView: View { 12 | 13 | init() { 14 | UITableViewHeaderFooterView.appearance().backgroundView = UIView() 15 | } 16 | 17 | var body: some View { 18 | ZStack { 19 | Image("Map") 20 | .opacity(0.5) 21 | List { 22 | Section( 23 | header: VStack { 24 | Text("Section 1") 25 | .font(.system(size: 40)) 26 | .frame(height: 60) 27 | }, 28 | content: { 29 | ForEach(1...20, id: \.self) { eachRowIndex in 30 | Text("Row \(eachRowIndex)") 31 | .listRowBackground(Color.clear) 32 | } 33 | } 34 | ) 35 | } 36 | .listStyle(.plain) 37 | .padding(.top, 40) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/Sketches/MarqueeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarqueeView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MarqueeView: View { 11 | 12 | struct Metrics { 13 | 14 | static let iconCount = CGFloat(9) 15 | static let marqueeIconCount = CGFloat(3) 16 | 17 | static let iconSize = CGFloat(95) 18 | static let cornerRadius = CGFloat(20) 19 | static let spacing = CGFloat(15) 20 | 21 | static let duration = iconCount * 1 22 | static let marqueeWidth = iconSize * marqueeIconCount + spacing * (marqueeIconCount - 1) 23 | static let iconsWidth = iconSize * iconCount + spacing * (iconCount - 1) 24 | static let remainder = (iconsWidth - marqueeWidth) / 2 25 | } 26 | 27 | @State var offset = Metrics.remainder 28 | 29 | var body: some View { 30 | VStack { 31 | HStack { 32 | Text("test title here") 33 | .fontWeight(.bold) 34 | .foregroundColor(.white) 35 | Spacer() 36 | } 37 | HStack(spacing: Metrics.spacing) { 38 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 39 | .fill(.red) 40 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 41 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 42 | .fill(.orange) 43 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 44 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 45 | .fill(.yellow) 46 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 47 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 48 | .fill(.green) 49 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 50 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 51 | .fill(.blue) 52 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 53 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 54 | .fill(.purple) 55 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 56 | // Repeat last three icon (same as `marqueeIconCount`). 57 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 58 | .fill(.red) 59 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 60 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 61 | .fill(.orange) 62 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 63 | RoundedRectangle(cornerRadius: Metrics.cornerRadius) 64 | .fill(.yellow) 65 | .frame(width: Metrics.iconSize, height: Metrics.iconSize) 66 | } 67 | .offset(x: offset, y: 0) 68 | .onAppear { 69 | withAnimation(Animation.linear(duration: Metrics.duration).repeatForever(autoreverses: false)) { 70 | offset = -Metrics.remainder 71 | } 72 | } 73 | } 74 | .frame(width: Metrics.marqueeWidth) 75 | .background(Color.gray) 76 | .clipShape(RoundedRectangle(cornerRadius: 10)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/Sketches/PaddingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaddingView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | struct PaddingView: View { 13 | 14 | init() { 15 | if #available(iOS 15.0, *) { 16 | UITableView.appearance().sectionHeaderTopPadding = 0 17 | } 18 | 19 | UITableView.appearance().backgroundColor = .clear 20 | UITableViewCell.appearance().backgroundColor = .clear 21 | UITableViewCell.appearance().backgroundView = UIView() 22 | UITableViewHeaderFooterView.appearance().backgroundView = UIView() // iOS 14+ 23 | } 24 | 25 | var body: some View { 26 | ZStack { 27 | Grid().opacity(0.1) 28 | VStack { 29 | Spacer(minLength: 60) 30 | List { 31 | Section( 32 | header: HStack { 33 | Text("Section Heaeder").font(.system(size: 40)) 34 | Spacer() 35 | } 36 | .frame(height: 60) 37 | .background(Grid().opacity(0.2)) 38 | .listRowInsets(.zero) 39 | .listRowBackground(Color.clear) 40 | .introspectTableViewHeaderFooterView { 41 | $0.backgroundView = UIView() // For iOS 13 42 | }, 43 | 44 | content: { 45 | ForEach(1...20, id: \.self) { eachRowIndex in 46 | Text("Row \(eachRowIndex)") 47 | .frame(height: 40) 48 | .listRowInsets(.zero) 49 | .listRowBackground( 50 | Rectangle() 51 | .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) 52 | ) 53 | } 54 | } 55 | ) 56 | } 57 | .listStyle(.plain) 58 | .introspectTableView { 59 | $0.separatorStyle = .none 60 | } 61 | .background(Grid().opacity(0.1)) 62 | .padding(.leading, 20) 63 | .padding(.trailing, 25) 64 | .environment(\.defaultMinListRowHeight, 40) 65 | } 66 | } 67 | .background(Color.black) 68 | .edgesIgnoringSafeArea(.all) 69 | } 70 | } 71 | 72 | extension EdgeInsets { 73 | 74 | static var zero = EdgeInsets() 75 | } 76 | 77 | 78 | struct Grid: View { 79 | 80 | var body: some View { 81 | Rectangle() 82 | .fill( 83 | ImagePaint(image: Image("10pt")) 84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/Sketches/TransparentTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransparentTabView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct TransparentTabView: View { 12 | 13 | init() { 14 | let transparentAppearence = UITabBarAppearance() 15 | transparentAppearence.configureWithTransparentBackground() 16 | UITabBar.appearance().standardAppearance = transparentAppearence 17 | } 18 | 19 | var body: some View { 20 | TabView { 21 | List { 22 | ForEach(1...40, id: \.self) { eachRowIndex in 23 | Text("Row \(eachRowIndex)") 24 | } 25 | } 26 | .listStyle(.plain) 27 | .tabItem { 28 | Image(systemName: "house.fill") 29 | Text("Home") 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/StaticRefreshControlView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaticRefreshControlView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | struct StaticRefreshControlView: View { 13 | 14 | var body: some View { 15 | List { 16 | ForEach(1...100, id: \.self) { eachRowIndex in 17 | Text("Row \(eachRowIndex)") 18 | } 19 | } 20 | .introspectTableView { tableView in 21 | tableView.refreshControl = UIRefreshControl() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Prototypes/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 14/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class ViewModel: ObservableObject { 12 | 13 | func fetch(_ completion: @escaping () -> Void) { 14 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 15 | completion() 16 | } 17 | } 18 | 19 | func fetch() async { 20 | await withCheckedContinuation { continuation in 21 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 22 | continuation.resume() 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 25.. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 20 | 21 | // Use a UIHostingController as window root view controller. 22 | if let windowScene = scene as? UIWindowScene { 23 | let window = UIWindow(windowScene: windowScene) 24 | window.layer.speed = UI.speed.layerSpeed 25 | window.rootViewController = 26 | // UIHostingController(rootView: RefreshControlTargetView()) 27 | AppHostingController(rootView: CitiesView()) 28 | self.window = window 29 | window.makeKeyAndVisible() 30 | } 31 | } 32 | 33 | func sceneDidDisconnect(_ scene: UIScene) { 34 | // Called as the scene is being released by the system. 35 | // This occurs shortly after the scene enters the background, or when its session is discarded. 36 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 37 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 38 | } 39 | 40 | func sceneDidBecomeActive(_ scene: UIScene) { 41 | // Called when the scene has moved from an inactive state to an active state. 42 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 43 | } 44 | 45 | func sceneWillResignActive(_ scene: UIScene) { 46 | // Called when the scene will move from an active state to an inactive state. 47 | // This may occur due to temporary interruptions (ex. an incoming phone call). 48 | } 49 | 50 | func sceneWillEnterForeground(_ scene: UIScene) { 51 | // Called as the scene transitions from the background to the foreground. 52 | // Use this method to undo the changes made on entering the background. 53 | } 54 | 55 | func sceneDidEnterBackground(_ scene: UIScene) { 56 | // Called as the scene transitions from the foreground to the background. 57 | // Use this method to save data, release shared resources, and store enough scene-specific state information 58 | // to restore the scene back to its current state. 59 | } 60 | 61 | 62 | } 63 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/Background/AlignedBackgroundView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlignedBackgroundView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 24/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct AlignedBackgroundView: View { 12 | 13 | let blur: Bool 14 | let screenFrame: CGRect 15 | 16 | var body: some View { 17 | baked 18 | } 19 | 20 | var blank: some View { 21 | UI.Color.background 22 | } 23 | 24 | var baked: some View { 25 | GeometryReader { geometry in 26 | baked(geometry: geometry, screenFrame: screenFrame) 27 | } 28 | } 29 | 30 | func baked(geometry: GeometryProxy, screenFrame: CGRect) -> some View { 31 | UI.Color.background 32 | .overlay( 33 | (blur ? UI.Image.opaqueBackgroundWithBlur : UI.Image.opaqueBackground) 34 | .offset( 35 | x: -geometry.frame(in: .global).origin.x + screenFrame.origin.x + UI.padding, 36 | y: -geometry.frame(in: .global).origin.y + screenFrame.origin.y 37 | ), alignment: .top 38 | ) 39 | .clipped() 40 | } 41 | 42 | var dynamic: some View { 43 | GeometryReader { geometry in 44 | dynamic(geometry: geometry, screenFrame: screenFrame) 45 | } 46 | } 47 | 48 | func dynamic(geometry: GeometryProxy, screenFrame: CGRect) -> some View { 49 | UI.Color.background 50 | .overlay( 51 | UI.Image.background 52 | .backgroundStyle() 53 | .background(UI.Color.background) 54 | .blur(radius: blur ? UI.Image.blur : 0) 55 | .offset( 56 | x: -geometry.frame(in: .global).origin.x + screenFrame.origin.x + UI.padding, 57 | y: -geometry.frame(in: .global).origin.y + screenFrame.origin.y 58 | ), alignment: .top 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/Background/BackgroundView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 23/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct BackgroundView: View { 12 | 13 | var body: some View { 14 | baked 15 | } 16 | 17 | var blank: some View { 18 | UI.Color.background 19 | } 20 | 21 | var baked: some View { 22 | UI.Color.background 23 | .overlay( 24 | VStack { 25 | UI.Image.opaqueBackground 26 | Spacer() 27 | } 28 | ) 29 | .clipped() 30 | } 31 | 32 | var transparent: some View { 33 | UI.Color.background 34 | .overlay( 35 | VStack { 36 | UI.Image.background 37 | .backgroundStyle() 38 | Spacer() 39 | } 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/CitiesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CitiesView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 2020. 07. 29.. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | import OpenWeather 11 | 12 | 13 | private struct ScreenFrameEnvironmentKey: EnvironmentKey { 14 | static let defaultValue: CGRect = .zero 15 | } 16 | 17 | extension EnvironmentValues { 18 | var screenFrame: CGRect { 19 | get { self[ScreenFrameEnvironmentKey.self] } 20 | set { self[ScreenFrameEnvironmentKey.self] = newValue } 21 | } 22 | } 23 | 24 | 25 | struct CitiesView: View { 26 | 27 | let viewModel = CitiesViewModel() 28 | 29 | var body: some View { 30 | ZStack { 31 | 32 | // Background. 33 | BackgroundView() 34 | 35 | // Cities. 36 | GeometryReader { geometry in 37 | ScrollView(.horizontal, showsIndicators: false) { 38 | HStack(spacing: 0) { 39 | ForEach(viewModel.cities) { eachCityViewModel in 40 | CityView( 41 | viewModel: CityViewModel( 42 | name: eachCityViewModel.name, 43 | location: .init( 44 | latitude: eachCityViewModel.location.latitude, 45 | longitude: eachCityViewModel.location.longitude 46 | ) 47 | ), 48 | width: geometry.size.width 49 | ) 50 | .environment(\.screenFrame, geometry.frame(in: .global)) 51 | } 52 | } 53 | } 54 | .introspectScrollView { 55 | $0.isPagingEnabled = true 56 | $0.set(speed: UI.speed) 57 | } 58 | } 59 | } 60 | .edgesIgnoringSafeArea(.bottom) 61 | .preferredColorScheme(.dark) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/CitiesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CitiesViewModel.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 20/03/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class CitiesViewModel { 12 | 13 | struct City: Identifiable { 14 | 15 | let name: String 16 | let location: Location 17 | 18 | struct Location { 19 | 20 | let latitude: Double 21 | let longitude: Double 22 | } 23 | 24 | var id: String { 25 | name 26 | } 27 | } 28 | 29 | let cities: [City] = [ 30 | .init( 31 | name: "San Francisco", 32 | location: .init(latitude: 37.773972, longitude: -122.431297) 33 | ), 34 | .init( 35 | name: "New York", 36 | location: .init(latitude: 40.730610, longitude: -73.935242) 37 | ), 38 | .init( 39 | name: "Paris", 40 | location: .init(latitude: 48.864716, longitude: 2.349014) 41 | ), 42 | .init( 43 | name: "London", 44 | location: .init(latitude: 51.509865, longitude: -0.118092) 45 | ), 46 | .init( 47 | name: "Moscow", 48 | location: .init(latitude: 55.751244, longitude: 37.618423) 49 | ), 50 | .init( 51 | name: "New Delhi", 52 | location: .init(latitude: 28.644800, longitude: 77.216721) 53 | ), 54 | .init( 55 | name: "Tokyo", 56 | location: .init(latitude: 35.652832, longitude: 139.839478) 57 | ), 58 | .init( 59 | name: "Sidney", 60 | location: .init(latitude: -33.865143, longitude: 151.209900) 61 | ), 62 | .init( 63 | name: "Honolulu", 64 | location: .init(latitude: 21.315603, longitude: -157.858093) 65 | ) 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/CityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CityView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Introspect 11 | import OpenWeather 12 | 13 | 14 | struct CityView: View { 15 | 16 | @ObservedObject var viewModel: CityViewModel 17 | let width: CGFloat 18 | 19 | init(viewModel: CityViewModel, width: CGFloat) { 20 | self.viewModel = viewModel 21 | self.width = width 22 | setupAppearence() 23 | } 24 | 25 | var body: some View { 26 | VStack(spacing: 0) { 27 | TitleView( 28 | name: viewModel.name, 29 | dateAndTimeString: viewModel.weatherListViewModel.dateAndTimeString 30 | ) 31 | Spacer(minLength: UI.padding - UI.topPadding) 32 | WeatherList(viewModel: viewModel.weatherListViewModel) 33 | .refreshable { 34 | await viewModel.fetch() 35 | } 36 | } 37 | .frame(width: width) 38 | .onAppear { 39 | viewModel.fetch() 40 | } 41 | } 42 | } 43 | 44 | 45 | extension CityView { 46 | 47 | func setupAppearence() { 48 | 49 | // Tighten extra padding above section header (if any). 50 | if #available(iOS 15.0, *) { 51 | UITableView.appearance().sectionHeaderTopPadding = UI.topPadding 52 | } 53 | 54 | // Hide indicators, separators. 55 | UITableView.appearance().showsVerticalScrollIndicator = false 56 | UITableView.appearance().separatorColor = .clear 57 | 58 | // iOS 13. 59 | UITableView.appearance().backgroundColor = .clear 60 | UITableViewCell.appearance().backgroundColor = .clear 61 | 62 | // Transparent section header. 63 | UITableViewHeaderFooterView.appearance().backgroundView = UIView() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/CityViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CityViewModel.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | import OpenWeather 10 | 11 | 12 | class CityViewModel: ObservableObject { 13 | 14 | let name: String 15 | let location: OpenWeather.Location 16 | static let useMockData = false 17 | 18 | @Published var weatherListViewModel: WeatherListViewModel = .empty 19 | 20 | init(name: String, location: Location) { 21 | self.name = name 22 | self.location = location 23 | } 24 | 25 | func fetch() async { 26 | await withCheckedContinuation { continuation in 27 | fetch { 28 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / Double(UI.speed.layerSpeed)) { // Some extra (fake) loading 29 | continuation.resume() 30 | } 31 | } 32 | } 33 | } 34 | 35 | func fetch(completion: (() -> Void)? = nil) { 36 | 37 | // Can spare API rate during UI development. 38 | if Self.useMockData { 39 | let mock = OpenWeather.HourlyForecast.mock(for: name) 40 | self.weatherListViewModel = .init(from: mock, name: self.name) 41 | completion?() 42 | return 43 | } 44 | 45 | OpenWeather.API.get(at: location) { [weak self] result in 46 | guard let self = self else { return } 47 | switch result { 48 | case .success(let weather): 49 | self.weatherListViewModel = .init(from: weather, name: self.name) 50 | case .failure(_): 51 | self.weatherListViewModel = .empty 52 | } 53 | completion?() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/Extensions/View+Introspect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Introspect.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 22/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Introspect 10 | 11 | 12 | extension View { 13 | 14 | public func introspectTableViewHeaderFooterView(customize: @escaping (UITableViewHeaderFooterView) -> Void) -> some View { 15 | introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: customize) 16 | } 17 | } 18 | 19 | 20 | extension View { 21 | 22 | func redLine(opacity: CGFloat = 1.0) -> some View { 23 | self 24 | // .background(Color.red.opacity(0.2 * opacity)) 25 | // .border(Color.red.opacity(opacity), width: 1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/OpenWeather/Mocks/New Delhi.json: -------------------------------------------------------------------------------- 1 | { 2 | "lat":28.6139, 3 | "lon":77.209, 4 | "timezone":"Asia/Kolkata", 5 | "timezone_offset":19800, 6 | "current":{ 7 | "dt":1647976636, 8 | "sunrise":1647996721, 9 | "sunset":1648040616, 10 | "temp":299.22, 11 | "feels_like":299.22, 12 | "pressure":1008, 13 | "humidity":57, 14 | "dew_point":290.04, 15 | "uvi":0, 16 | "clouds":0, 17 | "visibility":3500, 18 | "wind_speed":1.54, 19 | "wind_deg":320, 20 | "weather":[ 21 | { 22 | "id":721, 23 | "main":"Haze", 24 | "description":"haze", 25 | "icon":"50n" 26 | } 27 | ] 28 | }, 29 | "hourly":[ 30 | { 31 | "dt":1647975600, 32 | "temp":299.22, 33 | "feels_like":299.22, 34 | "pressure":1008, 35 | "humidity":57, 36 | "dew_point":290.04, 37 | "uvi":0, 38 | "clouds":0, 39 | "visibility":10000, 40 | "wind_speed":2.91, 41 | "wind_deg":281, 42 | "wind_gust":8.37, 43 | "weather":[ 44 | { 45 | "id":800, 46 | "main":"Clear", 47 | "description":"clear sky", 48 | "icon":"01n" 49 | } 50 | ], 51 | "pop":0 52 | }, 53 | { 54 | "dt":1647979200, 55 | "temp":299.33, 56 | "feels_like":299.33, 57 | "pressure":1008, 58 | "humidity":47, 59 | "dew_point":287.14, 60 | "uvi":0, 61 | "clouds":0, 62 | "visibility":10000, 63 | "wind_speed":2.46, 64 | "wind_deg":302, 65 | "wind_gust":7.59, 66 | "weather":[ 67 | { 68 | "id":800, 69 | "main":"Clear", 70 | "description":"clear sky", 71 | "icon":"01n" 72 | } 73 | ], 74 | "pop":0 75 | }, 76 | { 77 | "dt":1647982800, 78 | "temp":299.16, 79 | "feels_like":299.16, 80 | "pressure":1008, 81 | "humidity":38, 82 | "dew_point":283.76, 83 | "uvi":0, 84 | "clouds":0, 85 | "visibility":10000, 86 | "wind_speed":2.41, 87 | "wind_deg":307, 88 | "wind_gust":7.02, 89 | "weather":[ 90 | { 91 | "id":800, 92 | "main":"Clear", 93 | "description":"clear sky", 94 | "icon":"01n" 95 | } 96 | ], 97 | "pop":0 98 | }, 99 | { 100 | "dt":1647986400, 101 | "temp":298.79, 102 | "feels_like":298.19, 103 | "pressure":1007, 104 | "humidity":30, 105 | "dew_point":279.94, 106 | "uvi":0, 107 | "clouds":0, 108 | "visibility":10000, 109 | "wind_speed":2.68, 110 | "wind_deg":295, 111 | "wind_gust":6.62, 112 | "weather":[ 113 | { 114 | "id":800, 115 | "main":"Clear", 116 | "description":"clear sky", 117 | "icon":"01n" 118 | } 119 | ], 120 | "pop":0 121 | }, 122 | { 123 | "dt":1647990000, 124 | "temp":298.16, 125 | "feels_like":297.26, 126 | "pressure":1007, 127 | "humidity":21, 128 | "dew_point":274.34, 129 | "uvi":0, 130 | "clouds":0, 131 | "visibility":10000, 132 | "wind_speed":2.7, 133 | "wind_deg":293, 134 | "wind_gust":6.21, 135 | "weather":[ 136 | { 137 | "id":800, 138 | "main":"Clear", 139 | "description":"clear sky", 140 | "icon":"01n" 141 | } 142 | ], 143 | "pop":0 144 | }, 145 | { 146 | "dt":1647993600, 147 | "temp":297.46, 148 | "feels_like":296.26, 149 | "pressure":1007, 150 | "humidity":12, 151 | "dew_point":266.41, 152 | "uvi":0, 153 | "clouds":0, 154 | "visibility":10000, 155 | "wind_speed":2.5, 156 | "wind_deg":289, 157 | "wind_gust":5.28, 158 | "weather":[ 159 | { 160 | "id":800, 161 | "main":"Clear", 162 | "description":"clear sky", 163 | "icon":"01n" 164 | } 165 | ], 166 | "pop":0 167 | }, 168 | { 169 | "dt":1647997200, 170 | "temp":297.09, 171 | "feels_like":295.85, 172 | "pressure":1008, 173 | "humidity":12, 174 | "dew_point":265.85, 175 | "uvi":0, 176 | "clouds":0, 177 | "visibility":10000, 178 | "wind_speed":2.8, 179 | "wind_deg":282, 180 | "wind_gust":5.62, 181 | "weather":[ 182 | { 183 | "id":800, 184 | "main":"Clear", 185 | "description":"clear sky", 186 | "icon":"01d" 187 | } 188 | ], 189 | "pop":0 190 | }, 191 | { 192 | "dt":1648000800, 193 | "temp":298.2, 194 | "feels_like":297.05, 195 | "pressure":1008, 196 | "humidity":11, 197 | "dew_point":265.88, 198 | "uvi":0.54, 199 | "clouds":0, 200 | "visibility":10000, 201 | "wind_speed":2.5, 202 | "wind_deg":290, 203 | "wind_gust":5.35, 204 | "weather":[ 205 | { 206 | "id":800, 207 | "main":"Clear", 208 | "description":"clear sky", 209 | "icon":"01d" 210 | } 211 | ], 212 | "pop":0 213 | }, 214 | { 215 | "dt":1648004400, 216 | "temp":300.82, 217 | "feels_like":299.51, 218 | "pressure":1009, 219 | "humidity":10, 220 | "dew_point":266.18, 221 | "uvi":1.92, 222 | "clouds":0, 223 | "visibility":10000, 224 | "wind_speed":4.01, 225 | "wind_deg":284, 226 | "wind_gust":6.34, 227 | "weather":[ 228 | { 229 | "id":800, 230 | "main":"Clear", 231 | "description":"clear sky", 232 | "icon":"01d" 233 | } 234 | ], 235 | "pop":0 236 | }, 237 | { 238 | "dt":1648008000, 239 | "temp":303.27, 240 | "feels_like":301.41, 241 | "pressure":1009, 242 | "humidity":9, 243 | "dew_point":266.96, 244 | "uvi":4.13, 245 | "clouds":0, 246 | "visibility":10000, 247 | "wind_speed":3.83, 248 | "wind_deg":285, 249 | "wind_gust":5.89, 250 | "weather":[ 251 | { 252 | "id":800, 253 | "main":"Clear", 254 | "description":"clear sky", 255 | "icon":"01d" 256 | } 257 | ], 258 | "pop":0 259 | }, 260 | { 261 | "dt":1648011600, 262 | "temp":306.08, 263 | "feels_like":303.71, 264 | "pressure":1009, 265 | "humidity":8, 266 | "dew_point":267.65, 267 | "uvi":6.61, 268 | "clouds":0, 269 | "visibility":10000, 270 | "wind_speed":3.82, 271 | "wind_deg":282, 272 | "wind_gust":5.62, 273 | "weather":[ 274 | { 275 | "id":800, 276 | "main":"Clear", 277 | "description":"clear sky", 278 | "icon":"01d" 279 | } 280 | ], 281 | "pop":0 282 | }, 283 | { 284 | "dt":1648015200, 285 | "temp":308.42, 286 | "feels_like":305.67, 287 | "pressure":1008, 288 | "humidity":7, 289 | "dew_point":267.89, 290 | "uvi":8.49, 291 | "clouds":0, 292 | "visibility":10000, 293 | "wind_speed":3.33, 294 | "wind_deg":284, 295 | "wind_gust":5.54, 296 | "weather":[ 297 | { 298 | "id":800, 299 | "main":"Clear", 300 | "description":"clear sky", 301 | "icon":"01d" 302 | } 303 | ], 304 | "pop":0 305 | }, 306 | { 307 | "dt":1648018800, 308 | "temp":309.98, 309 | "feels_like":306.96, 310 | "pressure":1007, 311 | "humidity":6, 312 | "dew_point":267.56, 313 | "uvi":9.04, 314 | "clouds":0, 315 | "visibility":10000, 316 | "wind_speed":3.46, 317 | "wind_deg":291, 318 | "wind_gust":6.37, 319 | "weather":[ 320 | { 321 | "id":800, 322 | "main":"Clear", 323 | "description":"clear sky", 324 | "icon":"01d" 325 | } 326 | ], 327 | "pop":0 328 | }, 329 | { 330 | "dt":1648022400, 331 | "temp":310.81, 332 | "feels_like":307.64, 333 | "pressure":1006, 334 | "humidity":6, 335 | "dew_point":266.7, 336 | "uvi":8.09, 337 | "clouds":0, 338 | "visibility":10000, 339 | "wind_speed":3.95, 340 | "wind_deg":296, 341 | "wind_gust":6.49, 342 | "weather":[ 343 | { 344 | "id":800, 345 | "main":"Clear", 346 | "description":"clear sky", 347 | "icon":"01d" 348 | } 349 | ], 350 | "pop":0 351 | }, 352 | { 353 | "dt":1648026000, 354 | "temp":311.34, 355 | "feels_like":308.03, 356 | "pressure":1004, 357 | "humidity":5, 358 | "dew_point":266.02, 359 | "uvi":5.98, 360 | "clouds":0, 361 | "visibility":10000, 362 | "wind_speed":4.95, 363 | "wind_deg":292, 364 | "wind_gust":6.87, 365 | "weather":[ 366 | { 367 | "id":800, 368 | "main":"Clear", 369 | "description":"clear sky", 370 | "icon":"01d" 371 | } 372 | ], 373 | "pop":0 374 | }, 375 | { 376 | "dt":1648029600, 377 | "temp":311.3, 378 | "feels_like":307.99, 379 | "pressure":1003, 380 | "humidity":5, 381 | "dew_point":266.55, 382 | "uvi":3.52, 383 | "clouds":0, 384 | "visibility":10000, 385 | "wind_speed":5.79, 386 | "wind_deg":287, 387 | "wind_gust":7.14, 388 | "weather":[ 389 | { 390 | "id":800, 391 | "main":"Clear", 392 | "description":"clear sky", 393 | "icon":"01d" 394 | } 395 | ], 396 | "pop":0 397 | }, 398 | { 399 | "dt":1648033200, 400 | "temp":310.76, 401 | "feels_like":307.6, 402 | "pressure":1003, 403 | "humidity":6, 404 | "dew_point":267.81, 405 | "uvi":1.46, 406 | "clouds":0, 407 | "visibility":10000, 408 | "wind_speed":6.43, 409 | "wind_deg":280, 410 | "wind_gust":7.24, 411 | "weather":[ 412 | { 413 | "id":800, 414 | "main":"Clear", 415 | "description":"clear sky", 416 | "icon":"01d" 417 | } 418 | ], 419 | "pop":0 420 | }, 421 | { 422 | "dt":1648036800, 423 | "temp":309.85, 424 | "feels_like":306.87, 425 | "pressure":1003, 426 | "humidity":7, 427 | "dew_point":268.65, 428 | "uvi":0.35, 429 | "clouds":0, 430 | "visibility":10000, 431 | "wind_speed":5.99, 432 | "wind_deg":273, 433 | "wind_gust":6.85, 434 | "weather":[ 435 | { 436 | "id":800, 437 | "main":"Clear", 438 | "description":"clear sky", 439 | "icon":"01d" 440 | } 441 | ], 442 | "pop":0 443 | }, 444 | { 445 | "dt":1648040400, 446 | "temp":307.79, 447 | "feels_like":305.15, 448 | "pressure":1003, 449 | "humidity":8, 450 | "dew_point":269.42, 451 | "uvi":0, 452 | "clouds":0, 453 | "visibility":10000, 454 | "wind_speed":5.34, 455 | "wind_deg":273, 456 | "wind_gust":9.32, 457 | "weather":[ 458 | { 459 | "id":800, 460 | "main":"Clear", 461 | "description":"clear sky", 462 | "icon":"01d" 463 | } 464 | ], 465 | "pop":0 466 | }, 467 | { 468 | "dt":1648044000, 469 | "temp":306.68, 470 | "feels_like":304.2, 471 | "pressure":1004, 472 | "humidity":9, 473 | "dew_point":269.74, 474 | "uvi":0, 475 | "clouds":0, 476 | "visibility":10000, 477 | "wind_speed":4.42, 478 | "wind_deg":274, 479 | "wind_gust":9.28, 480 | "weather":[ 481 | { 482 | "id":800, 483 | "main":"Clear", 484 | "description":"clear sky", 485 | "icon":"01n" 486 | } 487 | ], 488 | "pop":0 489 | }, 490 | { 491 | "dt":1648047600, 492 | "temp":305.75, 493 | "feels_like":303.42, 494 | "pressure":1005, 495 | "humidity":10, 496 | "dew_point":269.9, 497 | "uvi":0, 498 | "clouds":0, 499 | "visibility":10000, 500 | "wind_speed":3.68, 501 | "wind_deg":274, 502 | "wind_gust":8.36, 503 | "weather":[ 504 | { 505 | "id":800, 506 | "main":"Clear", 507 | "description":"clear sky", 508 | "icon":"01n" 509 | } 510 | ], 511 | "pop":0 512 | }, 513 | { 514 | "dt":1648051200, 515 | "temp":304.72, 516 | "feels_like":302.56, 517 | "pressure":1006, 518 | "humidity":10, 519 | "dew_point":270.1, 520 | "uvi":0, 521 | "clouds":0, 522 | "visibility":10000, 523 | "wind_speed":2.99, 524 | "wind_deg":270, 525 | "wind_gust":6.38, 526 | "weather":[ 527 | { 528 | "id":800, 529 | "main":"Clear", 530 | "description":"clear sky", 531 | "icon":"01n" 532 | } 533 | ], 534 | "pop":0 535 | }, 536 | { 537 | "dt":1648054800, 538 | "temp":303.52, 539 | "feels_like":301.58, 540 | "pressure":1006, 541 | "humidity":11, 542 | "dew_point":270.28, 543 | "uvi":0, 544 | "clouds":0, 545 | "visibility":10000, 546 | "wind_speed":3.85, 547 | "wind_deg":255, 548 | "wind_gust":8.3, 549 | "weather":[ 550 | { 551 | "id":800, 552 | "main":"Clear", 553 | "description":"clear sky", 554 | "icon":"01n" 555 | } 556 | ], 557 | "pop":0 558 | }, 559 | { 560 | "dt":1648058400, 561 | "temp":302.64, 562 | "feels_like":300.88, 563 | "pressure":1006, 564 | "humidity":12, 565 | "dew_point":270.44, 566 | "uvi":0, 567 | "clouds":0, 568 | "visibility":10000, 569 | "wind_speed":3.06, 570 | "wind_deg":257, 571 | "wind_gust":8.06, 572 | "weather":[ 573 | { 574 | "id":800, 575 | "main":"Clear", 576 | "description":"clear sky", 577 | "icon":"01n" 578 | } 579 | ], 580 | "pop":0 581 | }, 582 | { 583 | "dt":1648062000, 584 | "temp":301.72, 585 | "feels_like":300.19, 586 | "pressure":1005, 587 | "humidity":13, 588 | "dew_point":270.48, 589 | "uvi":0, 590 | "clouds":0, 591 | "visibility":10000, 592 | "wind_speed":1.85, 593 | "wind_deg":274, 594 | "wind_gust":5.11, 595 | "weather":[ 596 | { 597 | "id":800, 598 | "main":"Clear", 599 | "description":"clear sky", 600 | "icon":"01n" 601 | } 602 | ], 603 | "pop":0 604 | }, 605 | { 606 | "dt":1648065600, 607 | "temp":300.64, 608 | "feels_like":299.43, 609 | "pressure":1005, 610 | "humidity":14, 611 | "dew_point":270.52, 612 | "uvi":0, 613 | "clouds":0, 614 | "visibility":10000, 615 | "wind_speed":1.8, 616 | "wind_deg":277, 617 | "wind_gust":5.18, 618 | "weather":[ 619 | { 620 | "id":800, 621 | "main":"Clear", 622 | "description":"clear sky", 623 | "icon":"01n" 624 | } 625 | ], 626 | "pop":0 627 | }, 628 | { 629 | "dt":1648069200, 630 | "temp":300.16, 631 | "feels_like":299.1, 632 | "pressure":1004, 633 | "humidity":14, 634 | "dew_point":270.53, 635 | "uvi":0, 636 | "clouds":0, 637 | "visibility":10000, 638 | "wind_speed":2.02, 639 | "wind_deg":293, 640 | "wind_gust":4.42, 641 | "weather":[ 642 | { 643 | "id":800, 644 | "main":"Clear", 645 | "description":"clear sky", 646 | "icon":"01n" 647 | } 648 | ], 649 | "pop":0 650 | }, 651 | { 652 | "dt":1648072800, 653 | "temp":299.56, 654 | "feels_like":299.56, 655 | "pressure":1004, 656 | "humidity":14, 657 | "dew_point":270.54, 658 | "uvi":0, 659 | "clouds":0, 660 | "visibility":10000, 661 | "wind_speed":2.19, 662 | "wind_deg":296, 663 | "wind_gust":5.65, 664 | "weather":[ 665 | { 666 | "id":800, 667 | "main":"Clear", 668 | "description":"clear sky", 669 | "icon":"01n" 670 | } 671 | ], 672 | "pop":0 673 | }, 674 | { 675 | "dt":1648076400, 676 | "temp":298.9, 677 | "feels_like":297.92, 678 | "pressure":1004, 679 | "humidity":15, 680 | "dew_point":270.65, 681 | "uvi":0, 682 | "clouds":0, 683 | "visibility":10000, 684 | "wind_speed":1.54, 685 | "wind_deg":274, 686 | "wind_gust":3.36, 687 | "weather":[ 688 | { 689 | "id":800, 690 | "main":"Clear", 691 | "description":"clear sky", 692 | "icon":"01n" 693 | } 694 | ], 695 | "pop":0 696 | }, 697 | { 698 | "dt":1648080000, 699 | "temp":298.59, 700 | "feels_like":297.61, 701 | "pressure":1004, 702 | "humidity":16, 703 | "dew_point":270.94, 704 | "uvi":0, 705 | "clouds":0, 706 | "visibility":10000, 707 | "wind_speed":1.15, 708 | "wind_deg":258, 709 | "wind_gust":1.58, 710 | "weather":[ 711 | { 712 | "id":800, 713 | "main":"Clear", 714 | "description":"clear sky", 715 | "icon":"01n" 716 | } 717 | ], 718 | "pop":0 719 | }, 720 | { 721 | "dt":1648083600, 722 | "temp":298.23, 723 | "feels_like":297.24, 724 | "pressure":1005, 725 | "humidity":17, 726 | "dew_point":271.38, 727 | "uvi":0, 728 | "clouds":0, 729 | "visibility":10000, 730 | "wind_speed":1.55, 731 | "wind_deg":286, 732 | "wind_gust":2.91, 733 | "weather":[ 734 | { 735 | "id":800, 736 | "main":"Clear", 737 | "description":"clear sky", 738 | "icon":"01d" 739 | } 740 | ], 741 | "pop":0 742 | }, 743 | { 744 | "dt":1648087200, 745 | "temp":299.32, 746 | "feels_like":299.32, 747 | "pressure":1005, 748 | "humidity":17, 749 | "dew_point":272.19, 750 | "uvi":0.58, 751 | "clouds":0, 752 | "visibility":10000, 753 | "wind_speed":0.93, 754 | "wind_deg":245, 755 | "wind_gust":1.86, 756 | "weather":[ 757 | { 758 | "id":800, 759 | "main":"Clear", 760 | "description":"clear sky", 761 | "icon":"01d" 762 | } 763 | ], 764 | "pop":0 765 | }, 766 | { 767 | "dt":1648090800, 768 | "temp":301.88, 769 | "feels_like":300.32, 770 | "pressure":1006, 771 | "humidity":15, 772 | "dew_point":272.65, 773 | "uvi":2.02, 774 | "clouds":0, 775 | "visibility":10000, 776 | "wind_speed":2.12, 777 | "wind_deg":254, 778 | "wind_gust":3.28, 779 | "weather":[ 780 | { 781 | "id":800, 782 | "main":"Clear", 783 | "description":"clear sky", 784 | "icon":"01d" 785 | } 786 | ], 787 | "pop":0 788 | }, 789 | { 790 | "dt":1648094400, 791 | "temp":304.47, 792 | "feels_like":302.34, 793 | "pressure":1007, 794 | "humidity":13, 795 | "dew_point":273.04, 796 | "uvi":4.31, 797 | "clouds":0, 798 | "visibility":10000, 799 | "wind_speed":3.28, 800 | "wind_deg":260, 801 | "wind_gust":5.05, 802 | "weather":[ 803 | { 804 | "id":800, 805 | "main":"Clear", 806 | "description":"clear sky", 807 | "icon":"01d" 808 | } 809 | ], 810 | "pop":0 811 | }, 812 | { 813 | "dt":1648098000, 814 | "temp":306.67, 815 | "feels_like":304.22, 816 | "pressure":1007, 817 | "humidity":12, 818 | "dew_point":273.39, 819 | "uvi":6.88, 820 | "clouds":0, 821 | "visibility":10000, 822 | "wind_speed":3.76, 823 | "wind_deg":259, 824 | "wind_gust":6.06, 825 | "weather":[ 826 | { 827 | "id":800, 828 | "main":"Clear", 829 | "description":"clear sky", 830 | "icon":"01d" 831 | } 832 | ], 833 | "pop":0 834 | }, 835 | { 836 | "dt":1648101600, 837 | "temp":308.81, 838 | "feels_like":306.07, 839 | "pressure":1005, 840 | "humidity":10, 841 | "dew_point":272.29, 842 | "uvi":8.83, 843 | "clouds":0, 844 | "visibility":10000, 845 | "wind_speed":3.87, 846 | "wind_deg":254, 847 | "wind_gust":7.13, 848 | "weather":[ 849 | { 850 | "id":800, 851 | "main":"Clear", 852 | "description":"clear sky", 853 | "icon":"01d" 854 | } 855 | ], 856 | "pop":0 857 | }, 858 | { 859 | "dt":1648105200, 860 | "temp":310.15, 861 | "feels_like":307.22, 862 | "pressure":1004, 863 | "humidity":9, 864 | "dew_point":271.92, 865 | "uvi":9.43, 866 | "clouds":0, 867 | "visibility":10000, 868 | "wind_speed":4.8, 869 | "wind_deg":258, 870 | "wind_gust":7.06, 871 | "weather":[ 872 | { 873 | "id":800, 874 | "main":"Clear", 875 | "description":"clear sky", 876 | "icon":"01d" 877 | } 878 | ], 879 | "pop":0 880 | }, 881 | { 882 | "dt":1648108800, 883 | "temp":310.89, 884 | "feels_like":307.82, 885 | "pressure":1003, 886 | "humidity":8, 887 | "dew_point":271.46, 888 | "uvi":8.44, 889 | "clouds":0, 890 | "visibility":10000, 891 | "wind_speed":5.12, 892 | "wind_deg":261, 893 | "wind_gust":8.28, 894 | "weather":[ 895 | { 896 | "id":800, 897 | "main":"Clear", 898 | "description":"clear sky", 899 | "icon":"01d" 900 | } 901 | ], 902 | "pop":0 903 | }, 904 | { 905 | "dt":1648112400, 906 | "temp":311.3, 907 | "feels_like":308.1, 908 | "pressure":1001, 909 | "humidity":7, 910 | "dew_point":270.45, 911 | "uvi":6.25, 912 | "clouds":0, 913 | "visibility":10000, 914 | "wind_speed":5.52, 915 | "wind_deg":261, 916 | "wind_gust":8.29, 917 | "weather":[ 918 | { 919 | "id":800, 920 | "main":"Clear", 921 | "description":"clear sky", 922 | "icon":"01d" 923 | } 924 | ], 925 | "pop":0 926 | }, 927 | { 928 | "dt":1648116000, 929 | "temp":311.32, 930 | "feels_like":308.12, 931 | "pressure":1001, 932 | "humidity":7, 933 | "dew_point":269.6, 934 | "uvi":3.6, 935 | "clouds":1, 936 | "visibility":10000, 937 | "wind_speed":5.79, 938 | "wind_deg":259, 939 | "wind_gust":8.09, 940 | "weather":[ 941 | { 942 | "id":800, 943 | "main":"Clear", 944 | "description":"clear sky", 945 | "icon":"01d" 946 | } 947 | ], 948 | "pop":0 949 | }, 950 | { 951 | "dt":1648119600, 952 | "temp":310.88, 953 | "feels_like":307.75, 954 | "pressure":1000, 955 | "humidity":7, 956 | "dew_point":268.93, 957 | "uvi":1.49, 958 | "clouds":2, 959 | "visibility":10000, 960 | "wind_speed":5.94, 961 | "wind_deg":261, 962 | "wind_gust":7.8, 963 | "weather":[ 964 | { 965 | "id":800, 966 | "main":"Clear", 967 | "description":"clear sky", 968 | "icon":"01d" 969 | } 970 | ], 971 | "pop":0 972 | }, 973 | { 974 | "dt":1648123200, 975 | "temp":310.13, 976 | "feels_like":307.11, 977 | "pressure":1000, 978 | "humidity":7, 979 | "dew_point":268.48, 980 | "uvi":0.37, 981 | "clouds":2, 982 | "visibility":10000, 983 | "wind_speed":5.15, 984 | "wind_deg":262, 985 | "wind_gust":7.32, 986 | "weather":[ 987 | { 988 | "id":800, 989 | "main":"Clear", 990 | "description":"clear sky", 991 | "icon":"01d" 992 | } 993 | ], 994 | "pop":0 995 | }, 996 | { 997 | "dt":1648126800, 998 | "temp":307.73, 999 | "feels_like":305.09, 1000 | "pressure":1001, 1001 | "humidity":8, 1002 | "dew_point":268.13, 1003 | "uvi":0, 1004 | "clouds":0, 1005 | "visibility":10000, 1006 | "wind_speed":3.82, 1007 | "wind_deg":263, 1008 | "wind_gust":8.02, 1009 | "weather":[ 1010 | { 1011 | "id":800, 1012 | "main":"Clear", 1013 | "description":"clear sky", 1014 | "icon":"01d" 1015 | } 1016 | ], 1017 | "pop":0 1018 | }, 1019 | { 1020 | "dt":1648130400, 1021 | "temp":306.01, 1022 | "feels_like":303.66, 1023 | "pressure":1002, 1024 | "humidity":8, 1025 | "dew_point":267.85, 1026 | "uvi":0, 1027 | "clouds":0, 1028 | "visibility":10000, 1029 | "wind_speed":2.94, 1030 | "wind_deg":261, 1031 | "wind_gust":7.17, 1032 | "weather":[ 1033 | { 1034 | "id":800, 1035 | "main":"Clear", 1036 | "description":"clear sky", 1037 | "icon":"01n" 1038 | } 1039 | ], 1040 | "pop":0 1041 | }, 1042 | { 1043 | "dt":1648134000, 1044 | "temp":304.76, 1045 | "feels_like":302.61, 1046 | "pressure":1002, 1047 | "humidity":9, 1048 | "dew_point":267.58, 1049 | "uvi":0, 1050 | "clouds":0, 1051 | "visibility":10000, 1052 | "wind_speed":2.58, 1053 | "wind_deg":255, 1054 | "wind_gust":6.81, 1055 | "weather":[ 1056 | { 1057 | "id":800, 1058 | "main":"Clear", 1059 | "description":"clear sky", 1060 | "icon":"01n" 1061 | } 1062 | ], 1063 | "pop":0 1064 | }, 1065 | { 1066 | "dt":1648137600, 1067 | "temp":303.87, 1068 | "feels_like":301.89, 1069 | "pressure":1003, 1070 | "humidity":9, 1071 | "dew_point":267.83, 1072 | "uvi":0, 1073 | "clouds":0, 1074 | "visibility":10000, 1075 | "wind_speed":2.99, 1076 | "wind_deg":258, 1077 | "wind_gust":7.21, 1078 | "weather":[ 1079 | { 1080 | "id":800, 1081 | "main":"Clear", 1082 | "description":"clear sky", 1083 | "icon":"01n" 1084 | } 1085 | ], 1086 | "pop":0 1087 | }, 1088 | { 1089 | "dt":1648141200, 1090 | "temp":303.09, 1091 | "feels_like":301.24, 1092 | "pressure":1003, 1093 | "humidity":11, 1094 | "dew_point":268.94, 1095 | "uvi":0, 1096 | "clouds":0, 1097 | "visibility":10000, 1098 | "wind_speed":2.29, 1099 | "wind_deg":233, 1100 | "wind_gust":4.6, 1101 | "weather":[ 1102 | { 1103 | "id":800, 1104 | "main":"Clear", 1105 | "description":"clear sky", 1106 | "icon":"01n" 1107 | } 1108 | ], 1109 | "pop":0 1110 | }, 1111 | { 1112 | "dt":1648144800, 1113 | "temp":302.42, 1114 | "feels_like":300.72, 1115 | "pressure":1003, 1116 | "humidity":12, 1117 | "dew_point":270.06, 1118 | "uvi":0, 1119 | "clouds":0, 1120 | "visibility":10000, 1121 | "wind_speed":2.54, 1122 | "wind_deg":248, 1123 | "wind_gust":6.78, 1124 | "weather":[ 1125 | { 1126 | "id":800, 1127 | "main":"Clear", 1128 | "description":"clear sky", 1129 | "icon":"01n" 1130 | } 1131 | ], 1132 | "pop":0 1133 | } 1134 | ] 1135 | } 1136 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/OpenWeather/Mocks/Paris.json: -------------------------------------------------------------------------------- 1 | { 2 | "lat":48.8566, 3 | "lon":2.3522, 4 | "timezone":"Europe/Paris", 5 | "timezone_offset":3600, 6 | "current":{ 7 | "dt":1647973187, 8 | "sunrise":1647928179, 9 | "sunset":1647972319, 10 | "temp":288.64, 11 | "feels_like":287.34, 12 | "pressure":1027, 13 | "humidity":42, 14 | "dew_point":275.8, 15 | "uvi":0, 16 | "clouds":0, 17 | "visibility":10000, 18 | "wind_speed":3.6, 19 | "wind_deg":100, 20 | "weather":[ 21 | { 22 | "id":800, 23 | "main":"Clear", 24 | "description":"clear sky", 25 | "icon":"01n" 26 | } 27 | ] 28 | }, 29 | "hourly":[ 30 | { 31 | "dt":1647972000, 32 | "temp":288.64, 33 | "feels_like":287.34, 34 | "pressure":1027, 35 | "humidity":42, 36 | "dew_point":275.8, 37 | "uvi":0, 38 | "clouds":0, 39 | "visibility":10000, 40 | "wind_speed":2.6, 41 | "wind_deg":131, 42 | "wind_gust":5.72, 43 | "weather":[ 44 | { 45 | "id":800, 46 | "main":"Clear", 47 | "description":"clear sky", 48 | "icon":"01d" 49 | } 50 | ], 51 | "pop":0 52 | }, 53 | { 54 | "dt":1647975600, 55 | "temp":288.34, 56 | "feels_like":287.04, 57 | "pressure":1027, 58 | "humidity":43, 59 | "dew_point":275.86, 60 | "uvi":0, 61 | "clouds":1, 62 | "visibility":10000, 63 | "wind_speed":2.3, 64 | "wind_deg":113, 65 | "wind_gust":5.74, 66 | "weather":[ 67 | { 68 | "id":800, 69 | "main":"Clear", 70 | "description":"clear sky", 71 | "icon":"01n" 72 | } 73 | ], 74 | "pop":0 75 | }, 76 | { 77 | "dt":1647979200, 78 | "temp":287.78, 79 | "feels_like":286.45, 80 | "pressure":1028, 81 | "humidity":44, 82 | "dew_point":275.67, 83 | "uvi":0, 84 | "clouds":1, 85 | "visibility":10000, 86 | "wind_speed":2.48, 87 | "wind_deg":105, 88 | "wind_gust":6.84, 89 | "weather":[ 90 | { 91 | "id":800, 92 | "main":"Clear", 93 | "description":"clear sky", 94 | "icon":"01n" 95 | } 96 | ], 97 | "pop":0 98 | }, 99 | { 100 | "dt":1647982800, 101 | "temp":286.94, 102 | "feels_like":285.55, 103 | "pressure":1028, 104 | "humidity":45, 105 | "dew_point":275.22, 106 | "uvi":0, 107 | "clouds":1, 108 | "visibility":10000, 109 | "wind_speed":2.36, 110 | "wind_deg":107, 111 | "wind_gust":6.87, 112 | "weather":[ 113 | { 114 | "id":800, 115 | "main":"Clear", 116 | "description":"clear sky", 117 | "icon":"01n" 118 | } 119 | ], 120 | "pop":0 121 | }, 122 | { 123 | "dt":1647986400, 124 | "temp":285.9, 125 | "feels_like":284.46, 126 | "pressure":1029, 127 | "humidity":47, 128 | "dew_point":274.88, 129 | "uvi":0, 130 | "clouds":2, 131 | "visibility":10000, 132 | "wind_speed":2.44, 133 | "wind_deg":112, 134 | "wind_gust":7.4, 135 | "weather":[ 136 | { 137 | "id":800, 138 | "main":"Clear", 139 | "description":"clear sky", 140 | "icon":"01n" 141 | } 142 | ], 143 | "pop":0 144 | }, 145 | { 146 | "dt":1647990000, 147 | "temp":284.57, 148 | "feels_like":283.15, 149 | "pressure":1029, 150 | "humidity":53, 151 | "dew_point":275.03, 152 | "uvi":0, 153 | "clouds":1, 154 | "visibility":10000, 155 | "wind_speed":2.34, 156 | "wind_deg":114, 157 | "wind_gust":6.84, 158 | "weather":[ 159 | { 160 | "id":800, 161 | "main":"Clear", 162 | "description":"clear sky", 163 | "icon":"01n" 164 | } 165 | ], 166 | "pop":0 167 | }, 168 | { 169 | "dt":1647993600, 170 | "temp":283.97, 171 | "feels_like":282.6, 172 | "pressure":1029, 173 | "humidity":57, 174 | "dew_point":275.51, 175 | "uvi":0, 176 | "clouds":1, 177 | "visibility":10000, 178 | "wind_speed":2.07, 179 | "wind_deg":117, 180 | "wind_gust":6.05, 181 | "weather":[ 182 | { 183 | "id":800, 184 | "main":"Clear", 185 | "description":"clear sky", 186 | "icon":"01n" 187 | } 188 | ], 189 | "pop":0 190 | }, 191 | { 192 | "dt":1647997200, 193 | "temp":283.41, 194 | "feels_like":282.03, 195 | "pressure":1030, 196 | "humidity":59, 197 | "dew_point":275.63, 198 | "uvi":0, 199 | "clouds":0, 200 | "visibility":10000, 201 | "wind_speed":1.94, 202 | "wind_deg":123, 203 | "wind_gust":5.54, 204 | "weather":[ 205 | { 206 | "id":800, 207 | "main":"Clear", 208 | "description":"clear sky", 209 | "icon":"01n" 210 | } 211 | ], 212 | "pop":0 213 | }, 214 | { 215 | "dt":1648000800, 216 | "temp":282.85, 217 | "feels_like":282.06, 218 | "pressure":1030, 219 | "humidity":60, 220 | "dew_point":275.32, 221 | "uvi":0, 222 | "clouds":0, 223 | "visibility":10000, 224 | "wind_speed":1.92, 225 | "wind_deg":125, 226 | "wind_gust":4.94, 227 | "weather":[ 228 | { 229 | "id":800, 230 | "main":"Clear", 231 | "description":"clear sky", 232 | "icon":"01n" 233 | } 234 | ], 235 | "pop":0 236 | }, 237 | { 238 | "dt":1648004400, 239 | "temp":282.26, 240 | "feels_like":281.42, 241 | "pressure":1030, 242 | "humidity":60, 243 | "dew_point":274.66, 244 | "uvi":0, 245 | "clouds":0, 246 | "visibility":10000, 247 | "wind_speed":1.87, 248 | "wind_deg":136, 249 | "wind_gust":4.53, 250 | "weather":[ 251 | { 252 | "id":800, 253 | "main":"Clear", 254 | "description":"clear sky", 255 | "icon":"01n" 256 | } 257 | ], 258 | "pop":0 259 | }, 260 | { 261 | "dt":1648008000, 262 | "temp":281.74, 263 | "feels_like":280.92, 264 | "pressure":1030, 265 | "humidity":60, 266 | "dew_point":274.15, 267 | "uvi":0, 268 | "clouds":0, 269 | "visibility":10000, 270 | "wind_speed":1.76, 271 | "wind_deg":148, 272 | "wind_gust":4.15, 273 | "weather":[ 274 | { 275 | "id":800, 276 | "main":"Clear", 277 | "description":"clear sky", 278 | "icon":"01n" 279 | } 280 | ], 281 | "pop":0 282 | }, 283 | { 284 | "dt":1648011600, 285 | "temp":281.29, 286 | "feels_like":280.62, 287 | "pressure":1030, 288 | "humidity":60, 289 | "dew_point":273.72, 290 | "uvi":0, 291 | "clouds":0, 292 | "visibility":10000, 293 | "wind_speed":1.55, 294 | "wind_deg":153, 295 | "wind_gust":2.89, 296 | "weather":[ 297 | { 298 | "id":800, 299 | "main":"Clear", 300 | "description":"clear sky", 301 | "icon":"01n" 302 | } 303 | ], 304 | "pop":0 305 | }, 306 | { 307 | "dt":1648015200, 308 | "temp":281.04, 309 | "feels_like":280.39, 310 | "pressure":1031, 311 | "humidity":59, 312 | "dew_point":273.42, 313 | "uvi":0, 314 | "clouds":0, 315 | "visibility":10000, 316 | "wind_speed":1.5, 317 | "wind_deg":160, 318 | "wind_gust":2.21, 319 | "weather":[ 320 | { 321 | "id":800, 322 | "main":"Clear", 323 | "description":"clear sky", 324 | "icon":"01d" 325 | } 326 | ], 327 | "pop":0 328 | }, 329 | { 330 | "dt":1648018800, 331 | "temp":281.89, 332 | "feels_like":281.42, 333 | "pressure":1031, 334 | "humidity":55, 335 | "dew_point":273.23, 336 | "uvi":0.28, 337 | "clouds":0, 338 | "visibility":10000, 339 | "wind_speed":1.45, 340 | "wind_deg":163, 341 | "wind_gust":2.71, 342 | "weather":[ 343 | { 344 | "id":800, 345 | "main":"Clear", 346 | "description":"clear sky", 347 | "icon":"01d" 348 | } 349 | ], 350 | "pop":0 351 | }, 352 | { 353 | "dt":1648022400, 354 | "temp":283.6, 355 | "feels_like":281.93, 356 | "pressure":1031, 357 | "humidity":47, 358 | "dew_point":272.5, 359 | "uvi":0.81, 360 | "clouds":0, 361 | "visibility":10000, 362 | "wind_speed":1.59, 363 | "wind_deg":166, 364 | "wind_gust":2.59, 365 | "weather":[ 366 | { 367 | "id":800, 368 | "main":"Clear", 369 | "description":"clear sky", 370 | "icon":"01d" 371 | } 372 | ], 373 | "pop":0 374 | }, 375 | { 376 | "dt":1648026000, 377 | "temp":285.41, 378 | "feels_like":283.71, 379 | "pressure":1031, 380 | "humidity":39, 381 | "dew_point":271.83, 382 | "uvi":1.62, 383 | "clouds":0, 384 | "visibility":10000, 385 | "wind_speed":1.48, 386 | "wind_deg":158, 387 | "wind_gust":2.23, 388 | "weather":[ 389 | { 390 | "id":800, 391 | "main":"Clear", 392 | "description":"clear sky", 393 | "icon":"01d" 394 | } 395 | ], 396 | "pop":0 397 | }, 398 | { 399 | "dt":1648029600, 400 | "temp":287.15, 401 | "feels_like":285.49, 402 | "pressure":1031, 403 | "humidity":34, 404 | "dew_point":271.39, 405 | "uvi":2.51, 406 | "clouds":0, 407 | "visibility":10000, 408 | "wind_speed":1.45, 409 | "wind_deg":141, 410 | "wind_gust":2.17, 411 | "weather":[ 412 | { 413 | "id":800, 414 | "main":"Clear", 415 | "description":"clear sky", 416 | "icon":"01d" 417 | } 418 | ], 419 | "pop":0 420 | }, 421 | { 422 | "dt":1648033200, 423 | "temp":288.68, 424 | "feels_like":287.07, 425 | "pressure":1031, 426 | "humidity":30, 427 | "dew_point":271.03, 428 | "uvi":3.18, 429 | "clouds":0, 430 | "visibility":10000, 431 | "wind_speed":1.65, 432 | "wind_deg":120, 433 | "wind_gust":2.43, 434 | "weather":[ 435 | { 436 | "id":800, 437 | "main":"Clear", 438 | "description":"clear sky", 439 | "icon":"01d" 440 | } 441 | ], 442 | "pop":0 443 | }, 444 | { 445 | "dt":1648036800, 446 | "temp":289.87, 447 | "feels_like":288.3, 448 | "pressure":1030, 449 | "humidity":27, 450 | "dew_point":270.83, 451 | "uvi":3.38, 452 | "clouds":0, 453 | "visibility":10000, 454 | "wind_speed":1.94, 455 | "wind_deg":111, 456 | "wind_gust":2.79, 457 | "weather":[ 458 | { 459 | "id":800, 460 | "main":"Clear", 461 | "description":"clear sky", 462 | "icon":"01d" 463 | } 464 | ], 465 | "pop":0 466 | }, 467 | { 468 | "dt":1648040400, 469 | "temp":290.67, 470 | "feels_like":289.16, 471 | "pressure":1030, 472 | "humidity":26, 473 | "dew_point":270.79, 474 | "uvi":3.05, 475 | "clouds":0, 476 | "visibility":10000, 477 | "wind_speed":2.1, 478 | "wind_deg":107, 479 | "wind_gust":3.06, 480 | "weather":[ 481 | { 482 | "id":800, 483 | "main":"Clear", 484 | "description":"clear sky", 485 | "icon":"01d" 486 | } 487 | ], 488 | "pop":0 489 | }, 490 | { 491 | "dt":1648044000, 492 | "temp":291.15, 493 | "feels_like":289.66, 494 | "pressure":1029, 495 | "humidity":25, 496 | "dew_point":270.62, 497 | "uvi":2.29, 498 | "clouds":0, 499 | "visibility":10000, 500 | "wind_speed":2.22, 501 | "wind_deg":99, 502 | "wind_gust":3.09, 503 | "weather":[ 504 | { 505 | "id":800, 506 | "main":"Clear", 507 | "description":"clear sky", 508 | "icon":"01d" 509 | } 510 | ], 511 | "pop":0 512 | }, 513 | { 514 | "dt":1648047600, 515 | "temp":291.3, 516 | "feels_like":289.82, 517 | "pressure":1029, 518 | "humidity":25, 519 | "dew_point":270.64, 520 | "uvi":1.41, 521 | "clouds":0, 522 | "visibility":10000, 523 | "wind_speed":2.57, 524 | "wind_deg":89, 525 | "wind_gust":3.31, 526 | "weather":[ 527 | { 528 | "id":800, 529 | "main":"Clear", 530 | "description":"clear sky", 531 | "icon":"01d" 532 | } 533 | ], 534 | "pop":0 535 | }, 536 | { 537 | "dt":1648051200, 538 | "temp":291.09, 539 | "feels_like":289.62, 540 | "pressure":1028, 541 | "humidity":26, 542 | "dew_point":271.34, 543 | "uvi":0.64, 544 | "clouds":0, 545 | "visibility":10000, 546 | "wind_speed":2.93, 547 | "wind_deg":84, 548 | "wind_gust":3.6, 549 | "weather":[ 550 | { 551 | "id":800, 552 | "main":"Clear", 553 | "description":"clear sky", 554 | "icon":"01d" 555 | } 556 | ], 557 | "pop":0 558 | }, 559 | { 560 | "dt":1648054800, 561 | "temp":290.25, 562 | "feels_like":288.83, 563 | "pressure":1029, 564 | "humidity":31, 565 | "dew_point":272.87, 566 | "uvi":0.19, 567 | "clouds":0, 568 | "visibility":10000, 569 | "wind_speed":2.99, 570 | "wind_deg":84, 571 | "wind_gust":3.88, 572 | "weather":[ 573 | { 574 | "id":800, 575 | "main":"Clear", 576 | "description":"clear sky", 577 | "icon":"01d" 578 | } 579 | ], 580 | "pop":0 581 | }, 582 | { 583 | "dt":1648058400, 584 | "temp":288.72, 585 | "feels_like":287.3, 586 | "pressure":1029, 587 | "humidity":37, 588 | "dew_point":273.94, 589 | "uvi":0, 590 | "clouds":0, 591 | "visibility":10000, 592 | "wind_speed":2.33, 593 | "wind_deg":86, 594 | "wind_gust":4.8, 595 | "weather":[ 596 | { 597 | "id":800, 598 | "main":"Clear", 599 | "description":"clear sky", 600 | "icon":"01d" 601 | } 602 | ], 603 | "pop":0 604 | }, 605 | { 606 | "dt":1648062000, 607 | "temp":287.53, 608 | "feels_like":286.15, 609 | "pressure":1029, 610 | "humidity":43, 611 | "dew_point":274.8, 612 | "uvi":0, 613 | "clouds":0, 614 | "visibility":10000, 615 | "wind_speed":1.69, 616 | "wind_deg":86, 617 | "wind_gust":3.72, 618 | "weather":[ 619 | { 620 | "id":800, 621 | "main":"Clear", 622 | "description":"clear sky", 623 | "icon":"01n" 624 | } 625 | ], 626 | "pop":0 627 | }, 628 | { 629 | "dt":1648065600, 630 | "temp":286.65, 631 | "feels_like":285.28, 632 | "pressure":1030, 633 | "humidity":47, 634 | "dew_point":275.45, 635 | "uvi":0, 636 | "clouds":0, 637 | "visibility":10000, 638 | "wind_speed":1.61, 639 | "wind_deg":87, 640 | "wind_gust":3.36, 641 | "weather":[ 642 | { 643 | "id":800, 644 | "main":"Clear", 645 | "description":"clear sky", 646 | "icon":"01n" 647 | } 648 | ], 649 | "pop":0 650 | }, 651 | { 652 | "dt":1648069200, 653 | "temp":285.94, 654 | "feels_like":284.63, 655 | "pressure":1030, 656 | "humidity":52, 657 | "dew_point":276.01, 658 | "uvi":0, 659 | "clouds":0, 660 | "visibility":10000, 661 | "wind_speed":1.56, 662 | "wind_deg":80, 663 | "wind_gust":2.72, 664 | "weather":[ 665 | { 666 | "id":800, 667 | "main":"Clear", 668 | "description":"clear sky", 669 | "icon":"01n" 670 | } 671 | ], 672 | "pop":0 673 | }, 674 | { 675 | "dt":1648072800, 676 | "temp":285.39, 677 | "feels_like":284.11, 678 | "pressure":1030, 679 | "humidity":55, 680 | "dew_point":276.59, 681 | "uvi":0, 682 | "clouds":1, 683 | "visibility":10000, 684 | "wind_speed":1.36, 685 | "wind_deg":71, 686 | "wind_gust":2, 687 | "weather":[ 688 | { 689 | "id":800, 690 | "main":"Clear", 691 | "description":"clear sky", 692 | "icon":"01n" 693 | } 694 | ], 695 | "pop":0 696 | }, 697 | { 698 | "dt":1648076400, 699 | "temp":284.95, 700 | "feels_like":283.7, 701 | "pressure":1030, 702 | "humidity":58, 703 | "dew_point":276.8, 704 | "uvi":0, 705 | "clouds":0, 706 | "visibility":10000, 707 | "wind_speed":1.29, 708 | "wind_deg":57, 709 | "wind_gust":1.52, 710 | "weather":[ 711 | { 712 | "id":800, 713 | "main":"Clear", 714 | "description":"clear sky", 715 | "icon":"01n" 716 | } 717 | ], 718 | "pop":0 719 | }, 720 | { 721 | "dt":1648080000, 722 | "temp":284.49, 723 | "feels_like":283.25, 724 | "pressure":1030, 725 | "humidity":60, 726 | "dew_point":276.82, 727 | "uvi":0, 728 | "clouds":0, 729 | "visibility":10000, 730 | "wind_speed":1.42, 731 | "wind_deg":54, 732 | "wind_gust":1.7, 733 | "weather":[ 734 | { 735 | "id":800, 736 | "main":"Clear", 737 | "description":"clear sky", 738 | "icon":"01n" 739 | } 740 | ], 741 | "pop":0 742 | }, 743 | { 744 | "dt":1648083600, 745 | "temp":284, 746 | "feels_like":282.73, 747 | "pressure":1030, 748 | "humidity":61, 749 | "dew_point":276.74, 750 | "uvi":0, 751 | "clouds":0, 752 | "visibility":10000, 753 | "wind_speed":1.51, 754 | "wind_deg":53, 755 | "wind_gust":1.9, 756 | "weather":[ 757 | { 758 | "id":800, 759 | "main":"Clear", 760 | "description":"clear sky", 761 | "icon":"01n" 762 | } 763 | ], 764 | "pop":0 765 | }, 766 | { 767 | "dt":1648087200, 768 | "temp":283.52, 769 | "feels_like":282.26, 770 | "pressure":1029, 771 | "humidity":63, 772 | "dew_point":276.53, 773 | "uvi":0, 774 | "clouds":0, 775 | "visibility":10000, 776 | "wind_speed":1.57, 777 | "wind_deg":51, 778 | "wind_gust":2.01, 779 | "weather":[ 780 | { 781 | "id":800, 782 | "main":"Clear", 783 | "description":"clear sky", 784 | "icon":"01n" 785 | } 786 | ], 787 | "pop":0 788 | }, 789 | { 790 | "dt":1648090800, 791 | "temp":283.08, 792 | "feels_like":282.67, 793 | "pressure":1029, 794 | "humidity":64, 795 | "dew_point":276.35, 796 | "uvi":0, 797 | "clouds":0, 798 | "visibility":10000, 799 | "wind_speed":1.55, 800 | "wind_deg":48, 801 | "wind_gust":2.27, 802 | "weather":[ 803 | { 804 | "id":800, 805 | "main":"Clear", 806 | "description":"clear sky", 807 | "icon":"01n" 808 | } 809 | ], 810 | "pop":0 811 | }, 812 | { 813 | "dt":1648094400, 814 | "temp":282.72, 815 | "feels_like":282.19, 816 | "pressure":1029, 817 | "humidity":64, 818 | "dew_point":276.2, 819 | "uvi":0, 820 | "clouds":0, 821 | "visibility":10000, 822 | "wind_speed":1.62, 823 | "wind_deg":44, 824 | "wind_gust":2.27, 825 | "weather":[ 826 | { 827 | "id":800, 828 | "main":"Clear", 829 | "description":"clear sky", 830 | "icon":"01n" 831 | } 832 | ], 833 | "pop":0 834 | }, 835 | { 836 | "dt":1648098000, 837 | "temp":282.36, 838 | "feels_like":281.81, 839 | "pressure":1029, 840 | "humidity":66, 841 | "dew_point":276.06, 842 | "uvi":0, 843 | "clouds":0, 844 | "visibility":10000, 845 | "wind_speed":1.58, 846 | "wind_deg":49, 847 | "wind_gust":2.41, 848 | "weather":[ 849 | { 850 | "id":800, 851 | "main":"Clear", 852 | "description":"clear sky", 853 | "icon":"01n" 854 | } 855 | ], 856 | "pop":0 857 | }, 858 | { 859 | "dt":1648101600, 860 | "temp":282.14, 861 | "feels_like":281.59, 862 | "pressure":1029, 863 | "humidity":66, 864 | "dew_point":276.04, 865 | "uvi":0, 866 | "clouds":0, 867 | "visibility":10000, 868 | "wind_speed":1.55, 869 | "wind_deg":51, 870 | "wind_gust":2.39, 871 | "weather":[ 872 | { 873 | "id":800, 874 | "main":"Clear", 875 | "description":"clear sky", 876 | "icon":"01d" 877 | } 878 | ], 879 | "pop":0 880 | }, 881 | { 882 | "dt":1648105200, 883 | "temp":282.99, 884 | "feels_like":282.7, 885 | "pressure":1030, 886 | "humidity":63, 887 | "dew_point":276.23, 888 | "uvi":0.3, 889 | "clouds":0, 890 | "visibility":10000, 891 | "wind_speed":1.42, 892 | "wind_deg":53, 893 | "wind_gust":3.35, 894 | "weather":[ 895 | { 896 | "id":800, 897 | "main":"Clear", 898 | "description":"clear sky", 899 | "icon":"01d" 900 | } 901 | ], 902 | "pop":0 903 | }, 904 | { 905 | "dt":1648108800, 906 | "temp":284.73, 907 | "feels_like":283.43, 908 | "pressure":1030, 909 | "humidity":57, 910 | "dew_point":276.28, 911 | "uvi":0.86, 912 | "clouds":0, 913 | "visibility":10000, 914 | "wind_speed":1.79, 915 | "wind_deg":65, 916 | "wind_gust":3.34, 917 | "weather":[ 918 | { 919 | "id":800, 920 | "main":"Clear", 921 | "description":"clear sky", 922 | "icon":"01d" 923 | } 924 | ], 925 | "pop":0 926 | }, 927 | { 928 | "dt":1648112400, 929 | "temp":286.64, 930 | "feels_like":285.35, 931 | "pressure":1030, 932 | "humidity":50, 933 | "dew_point":276.15, 934 | "uvi":1.7, 935 | "clouds":0, 936 | "visibility":10000, 937 | "wind_speed":1.95, 938 | "wind_deg":72, 939 | "wind_gust":3.53, 940 | "weather":[ 941 | { 942 | "id":800, 943 | "main":"Clear", 944 | "description":"clear sky", 945 | "icon":"01d" 946 | } 947 | ], 948 | "pop":0 949 | }, 950 | { 951 | "dt":1648116000, 952 | "temp":288.34, 953 | "feels_like":287.04, 954 | "pressure":1030, 955 | "humidity":43, 956 | "dew_point":275.87, 957 | "uvi":2.65, 958 | "clouds":0, 959 | "visibility":10000, 960 | "wind_speed":2.24, 961 | "wind_deg":72, 962 | "wind_gust":3.98, 963 | "weather":[ 964 | { 965 | "id":800, 966 | "main":"Clear", 967 | "description":"clear sky", 968 | "icon":"01d" 969 | } 970 | ], 971 | "pop":0 972 | }, 973 | { 974 | "dt":1648119600, 975 | "temp":289.72, 976 | "feels_like":288.42, 977 | "pressure":1029, 978 | "humidity":38, 979 | "dew_point":275.09, 980 | "uvi":3.34, 981 | "clouds":0, 982 | "visibility":10000, 983 | "wind_speed":2.67, 984 | "wind_deg":72, 985 | "wind_gust":4, 986 | "weather":[ 987 | { 988 | "id":800, 989 | "main":"Clear", 990 | "description":"clear sky", 991 | "icon":"01d" 992 | } 993 | ], 994 | "pop":0 995 | }, 996 | { 997 | "dt":1648123200, 998 | "temp":290.71, 999 | "feels_like":289.38, 1000 | "pressure":1029, 1001 | "humidity":33, 1002 | "dew_point":274.15, 1003 | "uvi":3.56, 1004 | "clouds":0, 1005 | "visibility":10000, 1006 | "wind_speed":2.98, 1007 | "wind_deg":70, 1008 | "wind_gust":3.78, 1009 | "weather":[ 1010 | { 1011 | "id":800, 1012 | "main":"Clear", 1013 | "description":"clear sky", 1014 | "icon":"01d" 1015 | } 1016 | ], 1017 | "pop":0 1018 | }, 1019 | { 1020 | "dt":1648126800, 1021 | "temp":291.33, 1022 | "feels_like":289.99, 1023 | "pressure":1028, 1024 | "humidity":30, 1025 | "dew_point":273.39, 1026 | "uvi":3.22, 1027 | "clouds":0, 1028 | "visibility":10000, 1029 | "wind_speed":3.14, 1030 | "wind_deg":65, 1031 | "wind_gust":3.65, 1032 | "weather":[ 1033 | { 1034 | "id":800, 1035 | "main":"Clear", 1036 | "description":"clear sky", 1037 | "icon":"01d" 1038 | } 1039 | ], 1040 | "pop":0 1041 | }, 1042 | { 1043 | "dt":1648130400, 1044 | "temp":291.6, 1045 | "feels_like":290.26, 1046 | "pressure":1027, 1047 | "humidity":29, 1048 | "dew_point":273.06, 1049 | "uvi":2.43, 1050 | "clouds":0, 1051 | "visibility":10000, 1052 | "wind_speed":3.27, 1053 | "wind_deg":63, 1054 | "wind_gust":3.54, 1055 | "weather":[ 1056 | { 1057 | "id":800, 1058 | "main":"Clear", 1059 | "description":"clear sky", 1060 | "icon":"01d" 1061 | } 1062 | ], 1063 | "pop":0 1064 | }, 1065 | { 1066 | "dt":1648134000, 1067 | "temp":291.62, 1068 | "feels_like":290.28, 1069 | "pressure":1027, 1070 | "humidity":29, 1071 | "dew_point":273.01, 1072 | "uvi":1.49, 1073 | "clouds":0, 1074 | "visibility":10000, 1075 | "wind_speed":3.5, 1076 | "wind_deg":60, 1077 | "wind_gust":3.7, 1078 | "weather":[ 1079 | { 1080 | "id":800, 1081 | "main":"Clear", 1082 | "description":"clear sky", 1083 | "icon":"01d" 1084 | } 1085 | ], 1086 | "pop":0 1087 | }, 1088 | { 1089 | "dt":1648137600, 1090 | "temp":291.19, 1091 | "feels_like":289.83, 1092 | "pressure":1027, 1093 | "humidity":30, 1094 | "dew_point":273.28, 1095 | "uvi":0.68, 1096 | "clouds":0, 1097 | "visibility":10000, 1098 | "wind_speed":3.65, 1099 | "wind_deg":59, 1100 | "wind_gust":3.85, 1101 | "weather":[ 1102 | { 1103 | "id":800, 1104 | "main":"Clear", 1105 | "description":"clear sky", 1106 | "icon":"01d" 1107 | } 1108 | ], 1109 | "pop":0 1110 | }, 1111 | { 1112 | "dt":1648141200, 1113 | "temp":290.39, 1114 | "feels_like":289.06, 1115 | "pressure":1027, 1116 | "humidity":34, 1117 | "dew_point":274.13, 1118 | "uvi":0.2, 1119 | "clouds":0, 1120 | "visibility":10000, 1121 | "wind_speed":3.59, 1122 | "wind_deg":60, 1123 | "wind_gust":4.34, 1124 | "weather":[ 1125 | { 1126 | "id":800, 1127 | "main":"Clear", 1128 | "description":"clear sky", 1129 | "icon":"01d" 1130 | } 1131 | ], 1132 | "pop":0 1133 | } 1134 | ] 1135 | } 1136 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/OpenWeather/OpenWeather+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenWeather+Extensions.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 20/03/2022. 6 | // 7 | 8 | import Foundation 9 | import OpenWeather 10 | 11 | 12 | extension OpenWeather.HourlyForecast { 13 | 14 | var description: String { 15 | currentWeather.weather.description 16 | } 17 | } 18 | 19 | 20 | extension OpenWeather.HourlyForecast.WeatherData { 21 | 22 | var description: String { 23 | weather.first?.main ?? "" 24 | } 25 | 26 | // From https://openweathermap.org/weather-conditions 27 | var imageName: String { 28 | guard let icon = weather.first?.icon else { 29 | return "questionmark.circle" 30 | } 31 | 32 | switch icon { 33 | // clear sky 34 | case "01d": return "sun.max" 35 | case "01n": return "moon.stars" 36 | // few clouds 37 | case "02d": return "cloud.sun" 38 | case "02n": return "cloud.moon" 39 | // scattered clouds 40 | case "03d": return "cloud" 41 | case "03n": return "cloud" 42 | // broken clouds 43 | case "04d": return "smoke" 44 | case "04n": return "smoke" 45 | // shower rain 46 | case "09d": return "cloud.rain" 47 | case "09n": return "cloud.rain" 48 | // rain 49 | case "10d": return "cloud.sun.rain" 50 | case "10n": return "cloud.moon.rain" 51 | // thunderstorm 52 | case "11d": return "cloud.bolt.rain" 53 | case "11n": return "cloud.bolt.rain" 54 | // snow 55 | case "13d": return "snow" 56 | case "13n": return "snow" 57 | // mist 58 | case "50d": return "cloud.fog" 59 | case "50n": return "cloud.fog" 60 | default: return "questionmark.circle" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/OpenWeather/OpenWeather+Mocks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenWeather+Mocks.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 22/03/2022. 6 | // 7 | 8 | import Foundation 9 | import OpenWeather 10 | 11 | 12 | extension OpenWeather.HourlyForecast { 13 | 14 | static func mock(for city: String) -> OpenWeather.HourlyForecast { 15 | 16 | // File. 17 | guard let jsonFilePath = 18 | Bundle.main.path(forResource: city, ofType: "json") ?? 19 | Bundle.main.path(forResource: "San Francisco", ofType: "json") else { 20 | preconditionFailure("Mock JSON is missing.") 21 | } 22 | 23 | // String. 24 | let jsonString: String 25 | do { 26 | jsonString = try String(contentsOfFile: jsonFilePath) 27 | } catch { 28 | preconditionFailure("Can't read mock JSON.") 29 | } 30 | 31 | // Data. 32 | guard let jsonData = jsonString.data(using: .utf8) else { 33 | preconditionFailure("Could not get mock JSON data.") 34 | } 35 | 36 | // Decode. 37 | let weather: OpenWeather.HourlyForecast 38 | let decoder = JSONDecoder() 39 | decoder.dateDecodingStrategy = .secondsSince1970 40 | do { 41 | weather = try decoder.decode(HourlyForecast.self, from: jsonData) 42 | } catch { 43 | preconditionFailure("Could not decode mock JSON data.") 44 | } 45 | 46 | return weather 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/Summary/Attributes/AttributeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributeView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct AttributeView: View { 12 | 13 | let image: String 14 | let name: String 15 | let value: String 16 | let unit: String 17 | 18 | var body: some View { 19 | HStack(spacing: 4) { 20 | Image(systemName: image) 21 | .iconStyle() 22 | VStack(alignment: .leading, spacing: 0) { 23 | Text(name) 24 | .attributeStyle() 25 | .removeTextCase() 26 | HStack(alignment: .bottom, spacing: 2) { 27 | Text(value) 28 | .valueStyle() 29 | .removeTextCase() 30 | Text(unit) 31 | .unitStyle() 32 | .removeTextCase() 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/Summary/Attributes/AttributesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttributesView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct AttributesView: View { 12 | 13 | let wind: String 14 | let humidity: String 15 | let uv: String 16 | 17 | var body: some View { 18 | HStack(spacing: spacing) { 19 | Spacer() 20 | AttributeView( 21 | image: "wind", 22 | name: "Wind", 23 | value: wind, 24 | unit: "Km/h" 25 | ) 26 | AttributeView( 27 | image: humidityImageName, 28 | name: "Humidity", 29 | value: humidity, 30 | unit: "%" 31 | ) 32 | AttributeView( 33 | image: "sun.max", 34 | name: "UV Index", 35 | value: uv, 36 | unit: "" 37 | ) 38 | Spacer() 39 | } 40 | .padding(.vertical, 16) 41 | } 42 | 43 | var spacing: CGFloat { 44 | UIScreen.main.bounds.size.width < 390 ? 10 : 20 45 | } 46 | 47 | var humidityImageName: String { 48 | if #available(iOS 14.0, *) { 49 | return "drop" 50 | } else { 51 | return "cloud.fog" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/Summary/SummaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SummaryView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct SummaryView: View { 12 | 13 | let imageName: String 14 | let celsius: String 15 | let description: String 16 | let wind: String 17 | let humidity: String 18 | let uv: String 19 | @Environment(\.screenFrame) var screenFrame: CGRect 20 | 21 | var body: some View { 22 | VStack(spacing: 0) { 23 | TemperatureView( 24 | imageName: imageName, 25 | celsius: celsius, 26 | description: description 27 | ) 28 | .frame(maxWidth: .infinity) // Occupy the entire `Section.header` 29 | AttributesView( 30 | wind: wind, 31 | humidity: humidity, 32 | uv: uv 33 | ) 34 | } 35 | .background( 36 | AlignedBackgroundView(blur: false, screenFrame: screenFrame) 37 | .mask( 38 | LinearGradient( 39 | gradient: 40 | Gradient( 41 | stops: [ 42 | .init(color: .white.opacity(1.0), location: 0.0), 43 | .init(color: .white.opacity(0.6), location: 0.5), 44 | .init(color: .white.opacity(0.0), location: 1.0) 45 | ] 46 | ), 47 | startPoint: UnitPoint(x: 0, y: 0.65), 48 | endPoint: UnitPoint(x: 0, y: 1.0) 49 | ) 50 | ) 51 | ) 52 | } 53 | } 54 | 55 | 56 | extension SummaryView { 57 | 58 | static let mock = SummaryView( 59 | imageName: "cloud.bolt.rain", 60 | celsius: "-165.4", 61 | description: "Few clouds", 62 | wind: "0.71", 63 | humidity: "85", 64 | uv: "1.2" 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/Summary/TemperatureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct TemperatureView: View { 12 | 13 | let imageName: String 14 | let celsius: String 15 | let description: String 16 | @Environment(\.screenFrame) var screenFrame: CGRect 17 | 18 | var body: some View { 19 | HStack(spacing: 30) { 20 | Image(systemName: imageName) 21 | .heroStyle() 22 | .redLine() 23 | VStack(alignment: .leading, spacing: 0) { 24 | Text("\(celsius) °C") 25 | .heroStyle() 26 | .lineLimit(1) 27 | .fixedSize(horizontal: false, vertical: true) 28 | .minimumScaleFactor(0.2) 29 | .redLine() 30 | Text(description) 31 | .subtitleStyle() 32 | .removeTextCase() 33 | .offset(x: 0, y: -6) 34 | .redLine() 35 | } 36 | .frame(maxWidth: .infinity, alignment: .leading) 37 | .redLine(opacity: 0.5) 38 | } 39 | .padding(.vertical, 20) 40 | .padding(.horizontal, 28) 41 | .featuredBackgroundStyle() 42 | .background(AlignedBackgroundView(blur: true, screenFrame: screenFrame)) 43 | .overlay( 44 | RoundedRectangle(cornerRadius: UI.cornerRadius) 45 | .strokeBorder(UI.Color.foreground.opacity(0.1), lineWidth: 1) 46 | ) 47 | .cornerRadius(UI.cornerRadius) 48 | .redLine(opacity: 0.5) 49 | } 50 | } 51 | 52 | 53 | extension View { 54 | 55 | func eraseToAnyView() -> AnyView { 56 | AnyView(self) 57 | } 58 | 59 | func removeTextCase() -> some View { 60 | if #available(iOS 14.0, *) { 61 | return self 62 | .textCase(.none) 63 | .eraseToAnyView() 64 | } else { 65 | return self 66 | .eraseToAnyView() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/TitleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TitleView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 19/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct TitleView: View { 12 | 13 | let name: String 14 | let dateAndTimeString: String 15 | 16 | var body: some View { 17 | Text(name) 18 | .titleStyle() 19 | Text(dateAndTimeString) 20 | .regularStyle() 21 | } 22 | } 23 | 24 | 25 | extension TitleView { 26 | 27 | static let mock = TitleView( 28 | name: "San Jose", 29 | dateAndTimeString: "Tue Sept 22 at 13:15" 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/Forecast/ForecastView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForecastView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 20/09/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | struct ForecastView: View { 13 | 14 | let viewModel: ForecastViewModel 15 | 16 | var body: some View { 17 | HStack(spacing: 8) { 18 | 19 | // Date and time. 20 | VStack(alignment: .leading, spacing: 0) { 21 | Text(viewModel.timeString) 22 | .regularStyle() 23 | Text(viewModel.dateString) 24 | .smallStyle() 25 | .frame(width: 60, alignment: .leading) 26 | } 27 | .frame(width: 60) 28 | .redLine(opacity: 0.2) 29 | 30 | // Icon. 31 | HStack { 32 | Image(systemName: viewModel.imageName) 33 | .iconStyle() 34 | Spacer() 35 | } 36 | .frame(width: 38) 37 | .redLine(opacity: 0.2) 38 | 39 | // Temperature. 40 | VStack(alignment: .leading, spacing: 2) { 41 | Text(viewModel.celsiusString) 42 | .regularStyle() 43 | TermperatureBarView( 44 | temperature: viewModel.temperature, 45 | smallestTemperature: viewModel.smallestTemperature, 46 | greatestTemperature: viewModel.greatestTemperature 47 | ) 48 | } 49 | .redLine(opacity: 0.2) 50 | 51 | // Wind. 52 | WindBarView(wind: viewModel.wind) 53 | .redLine(opacity: 0.2) 54 | 55 | } 56 | .frame(height: UI.rowHeight) 57 | .padding(.horizontal, UI.padding) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/Forecast/ForecastViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForecastViewModel.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/03/2022. 6 | // 7 | 8 | import Foundation 9 | import OpenWeather 10 | 11 | 12 | class ForecastViewModel: ObservableObject, Identifiable { 13 | 14 | let id: String = UUID().uuidString 15 | let time: Date 16 | let imageName: String 17 | let temperature: Double 18 | let smallestTemperature: Double 19 | let greatestTemperature: Double 20 | let wind: Double 21 | 22 | init() { 23 | self.time = Date() 24 | self.imageName = "minus" 25 | self.temperature = 273.15 26 | self.smallestTemperature = 273.15 27 | self.greatestTemperature = 273.15 28 | self.wind = 0 29 | } 30 | 31 | init( 32 | weather: OpenWeather.HourlyForecast.WeatherData, 33 | smallestTemperature: Double, 34 | greatestTemperature: Double 35 | ) { 36 | self.time = weather.time 37 | self.imageName = weather.imageName 38 | self.temperature = weather.temperature 39 | self.smallestTemperature = smallestTemperature 40 | self.greatestTemperature = greatestTemperature 41 | self.wind = weather.windSpeed 42 | } 43 | } 44 | 45 | 46 | extension ForecastViewModel { 47 | 48 | var celsius: Double { 49 | temperature - 273.15 50 | } 51 | 52 | var celsiusString: String { 53 | String(format: "%.1f °C", celsius) 54 | } 55 | 56 | var dateString: String { 57 | DateFormatter().with { 58 | $0.dateFormat = "EEE MMM d" 59 | }.string(from: time) 60 | } 61 | 62 | var timeString: String { 63 | DateFormatter().with { 64 | $0.dateFormat = "HH:mm" 65 | }.string(from: time) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/Forecast/TemperatureBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemperatureBarView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 22/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct TermperatureBarView: View { 12 | 13 | private let negativePercentage: CGFloat 14 | private let positivePercentage: CGFloat 15 | private let scale: CGFloat 16 | 17 | init( 18 | temperature: Double, 19 | smallestTemperature: Double, 20 | greatestTemperature: Double 21 | ) { 22 | // Conversion. 23 | let smallestCelsius = smallestTemperature - 273.15 24 | let greatestCelsius = greatestTemperature - 273.15 25 | let celsius = temperature - 273.15 26 | 27 | // Determine scale. 28 | let celsiusLimit = max(-smallestCelsius, greatestCelsius) 29 | switch celsiusLimit { 30 | case 0...10 : self.scale = 10 31 | case 10...20 : self.scale = 20 32 | case 20...30 : self.scale = 30 33 | case 30...40 : self.scale = 40 34 | default: self.scale = celsiusLimit 35 | } 36 | 37 | // Scale display values. 38 | self.negativePercentage = celsius <= 0 39 | ? celsius / -scale 40 | : .leastNonzeroMagnitude 41 | self.positivePercentage = celsius >= 0 42 | ? celsius / scale 43 | : .leastNonzeroMagnitude 44 | } 45 | 46 | var body: some View { 47 | HStack(spacing: UI.lineWidth) { 48 | RoundedRectangle(cornerRadius: UI.lineWidth / 2.0) 49 | .foregroundColor(UI.Color.gray.opacity(0.1)) 50 | .frame(height: UI.lineWidth) 51 | .overlay( 52 | GeometryReader { geometry in 53 | HStack { 54 | Spacer() 55 | RoundedRectangle(cornerRadius: UI.lineWidth) 56 | .foregroundColor(UI.Color.gray) 57 | .frame( 58 | width: geometry.size.width * negativePercentage, 59 | height: UI.lineWidth 60 | ) 61 | } 62 | } 63 | ) 64 | RoundedRectangle(cornerRadius: UI.lineWidth / 2.0) 65 | .foregroundColor(UI.Color.green.opacity(0.1)) 66 | .frame(height: UI.lineWidth) 67 | .overlay( 68 | GeometryReader { geometry in 69 | HStack { 70 | RoundedRectangle(cornerRadius: UI.lineWidth) 71 | .foregroundColor(UI.Color.green.opacity(0.5)) 72 | .frame( 73 | width: geometry.size.width * positivePercentage, 74 | height: UI.lineWidth 75 | ) 76 | Spacer() 77 | } 78 | } 79 | ) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/Forecast/WindBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindBarView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 23/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct WindBarView: View { 12 | 13 | private let wind: String 14 | private let scale: Double = 40 // Km/h 15 | private static let count = 8 16 | private var capsules: [Bool] = Array(repeating: false, count: Self.count) 17 | 18 | init(wind: Double) { 19 | self.wind = String(format: "%.1f", wind) 20 | self.capsules = capsules.enumerated().map { eachIndex, _ -> Bool in 21 | let eachMinimum = Double(eachIndex) * scale / Double(capsules.count) 22 | return wind > eachMinimum 23 | } 24 | } 25 | 26 | var body: some View { 27 | VStack(alignment: .leading, spacing: 2) { 28 | HStack(spacing: 2) { 29 | Text(wind) 30 | .smallStyle() 31 | Text("Km/h") 32 | .smallStyle() 33 | .opacity(0.5) 34 | } 35 | HStack(alignment: .bottom, spacing: 2) { 36 | ForEach(capsules.indices, id: \.self) { eachIndex in 37 | RoundedRectangle(cornerRadius: 2) 38 | .foregroundColor(UI.Color.gray.opacity(capsules[eachIndex] ? 0.8 : 0.2)) 39 | .frame(width: 4, height: 4 + CGFloat(eachIndex)) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/WeatherList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherList.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 22/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct WeatherList: View { 12 | 13 | let viewModel: WeatherListViewModel 14 | 15 | var body: some View { 16 | List { 17 | Section( 18 | header: 19 | SummaryView( 20 | imageName: viewModel.imageName, 21 | celsius: viewModel.celsius, 22 | description: viewModel.description, 23 | wind: viewModel.wind, 24 | humidity: viewModel.humidity, 25 | uv: viewModel.uv 26 | ) 27 | .listRowInsets(.zero) 28 | .introspectTableViewHeaderFooterView { 29 | $0.backgroundView = UIView() // iOS 13 30 | }, 31 | content: { 32 | ForEach( 33 | Array(viewModel.items.enumerated()), 34 | id: \.offset 35 | ) { eachIndex, eachViewModel in 36 | WeatherListRowView( 37 | isFirst: eachIndex == 0, 38 | isLast: eachIndex == viewModel.items.count - 1, 39 | viewModel: eachViewModel 40 | ) 41 | } 42 | } 43 | ) 44 | } 45 | .listStyle(.plain) 46 | .clipShape(RoundedRectangle(cornerRadius: UI.cornerRadius)) 47 | .padding(.horizontal, UI.padding) 48 | .padding(.bottom, UI.padding) 49 | .edgesIgnoringSafeArea(.bottom) // iOS 13 50 | .environment(\.defaultMinListRowHeight, UI.rowHeight) 51 | .introspectTableView { 52 | $0.separatorStyle = .none // iOS 13 53 | $0.set(speed: UI.speed) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/WeatherListRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherListRowView.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 20/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct WeatherListRowView: View { 12 | 13 | let isFirst: Bool 14 | let isLast: Bool 15 | let viewModel: ForecastViewModel 16 | @Environment(\.screenFrame) var screenFrame: CGRect 17 | 18 | var body: some View { 19 | return ForecastView(viewModel: viewModel) 20 | .listRowBackground( 21 | UI.Color.darkGray.opacity(0.5) 22 | .background(AlignedBackgroundView(blur: true, screenFrame: screenFrame)) 23 | .clipShape( 24 | RowShape(isFirst: isFirst, isLast: isLast) 25 | ) 26 | .padding(.bottom, bottomPadding) 27 | ) 28 | .listRowInsets(insets) 29 | } 30 | 31 | var insets: EdgeInsets { 32 | if isFirst { 33 | return EdgeInsets(top: UI.padding / 2, leading: 0, bottom: 0, trailing: 0) 34 | } else if isLast { 35 | return EdgeInsets(top: 0, leading: 0, bottom: UI.padding / 2 + bottomPadding, trailing: 0) 36 | } else { 37 | return .zero 38 | } 39 | } 40 | 41 | var bottomPadding: CGFloat { 42 | if #available(iOS 14, *) { 43 | return 0 44 | } else { 45 | return isLast ? 16 : 0 46 | } 47 | } 48 | } 49 | 50 | struct RowShape: Shape { 51 | 52 | let isFirst: Bool 53 | let isLast: Bool 54 | 55 | // Returns either a top cap, a rectangle, or a bottom cap shape. 56 | func path(in rect: CGRect) -> Path { 57 | let path: UIBezierPath 58 | if isFirst { 59 | let enlarged = rect.with(minHeight: UI.cornerRadius * 2) 60 | path = UIBezierPath( 61 | roundedRect: enlarged, 62 | byRoundingCorners: [.topLeft, .topRight], 63 | cornerRadii: UI.cornerRadius.size 64 | ) 65 | let hole = UIBezierPath( 66 | rect: CGRect( 67 | x: rect.origin.x, 68 | y: rect.origin.y + rect.height, 69 | width: rect.size.width, 70 | height: enlarged.size.height - rect.size.height 71 | ) 72 | ) 73 | path.append(hole.reversing()) 74 | } else if isLast { 75 | let enlarged = rect.with(minHeight: UI.cornerRadius * 2) 76 | path = UIBezierPath( 77 | roundedRect: enlarged, 78 | byRoundingCorners: [.bottomLeft, .bottomRight], 79 | cornerRadii: UI.cornerRadius.size 80 | ) 81 | let hole = UIBezierPath( 82 | rect: CGRect( 83 | x: rect.origin.x, 84 | y: rect.origin.y, 85 | width: rect.size.width, 86 | height: enlarged.size.height - rect.size.height 87 | ) 88 | ) 89 | path.append(hole.reversing()) 90 | let translation = CGAffineTransform(translationX: 0, y: -(enlarged.size.height - rect.size.height)) 91 | path.apply(translation) 92 | } else { 93 | path = UIBezierPath(rect: rect) 94 | } 95 | return Path(path.cgPath) 96 | } 97 | } 98 | 99 | 100 | extension CGFloat { 101 | 102 | var size: CGSize { 103 | .init(width: self, height: self) 104 | } 105 | } 106 | 107 | 108 | extension CGRect { 109 | 110 | func with(minHeight: CGFloat) -> CGRect { 111 | CGRect( 112 | origin: self.origin, 113 | size: .init( 114 | width: self.size.width, 115 | height: max(self.size.height, minHeight) 116 | ) 117 | ) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/City/WeatherList/WeatherListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherListViewModel.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 22/03/2022. 6 | // 7 | 8 | import Foundation 9 | import OpenWeather 10 | 11 | 12 | struct WeatherListViewModel { 13 | 14 | let time: Date 15 | let imageName: String 16 | let celsius: String 17 | let description: String 18 | let wind: String 19 | let humidity: String 20 | let uv: String 21 | let items: [ForecastViewModel] 22 | } 23 | 24 | 25 | extension WeatherListViewModel { 26 | 27 | init(from hourlyForecast: OpenWeather.HourlyForecast, name: String) { 28 | let weather = hourlyForecast.currentWeather 29 | self.time = weather.time 30 | self.imageName = weather.imageName 31 | self.celsius = String(format: "%.1f", weather.temperature - 273.15) 32 | self.description = weather.description 33 | self.wind = String(format: "%.2f", weather.windSpeed) 34 | self.humidity = String(format: "%.0f", weather.humidity) 35 | self.uv = String(format: "%.1f", weather.uvIndex) 36 | 37 | // Temperature range. 38 | let smallestTemperature = hourlyForecast.hourlyWeather 39 | .reduce(Double.greatestFiniteMagnitude) { 40 | min($0, $1.temperature) 41 | } 42 | let greatestTemperature = hourlyForecast.hourlyWeather 43 | .reduce(-Double.greatestFiniteMagnitude) { 44 | max($0, $1.temperature) 45 | } 46 | 47 | // Forecast items. 48 | self.items = hourlyForecast.hourlyWeather.map { 49 | ForecastViewModel( 50 | weather: $0, 51 | smallestTemperature: smallestTemperature, 52 | greatestTemperature: greatestTemperature 53 | ) 54 | 55 | } 56 | } 57 | 58 | static let empty = WeatherListViewModel( 59 | time: Date(), 60 | imageName: "circle", 61 | celsius: "0", 62 | description: "-", 63 | wind: "0.00", 64 | humidity: "0", 65 | uv: "0.0", 66 | items: Array(repeating: 1, count: 20).map { _ in ForecastViewModel() } 67 | ) 68 | 69 | var dateAndTimeString: String { 70 | DateFormatter().with { 71 | $0.dateFormat = "E MMM d 'at' HH:mm" 72 | }.string(from: time) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_Refresh/Views/UI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UI.swift 3 | // SwiftUI_Pull_to_Refresh 4 | // 5 | // Created by Geri Borbás on 18/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | struct UI { 12 | 13 | static let padding = CGFloat(16) 14 | static var topPadding: CGFloat { 15 | if #available(iOS 15.0, *) { 16 | return 8 17 | } else { 18 | return 0 19 | } 20 | } 21 | 22 | static let cornerRadius = CGFloat(32) 23 | static let rowHeight = CGFloat(40) 24 | static let lineWidth = CGFloat(4) 25 | 26 | struct Speed { 27 | 28 | let layerSpeed: Float 29 | let decelerationRate: CGFloat 30 | let contentOffsetAnimationDuration: CGFloat 31 | let pagingFriction: CGFloat 32 | 33 | init( 34 | layerSpeed: Float = 1.0, 35 | decelerationRate: CGFloat = 0.998, 36 | contentOffsetAnimationDuration: CGFloat = 0.3, 37 | pagingFriction: CGFloat = 0.9702286931818183 38 | ) { 39 | self.layerSpeed = layerSpeed 40 | self.decelerationRate = decelerationRate 41 | self.contentOffsetAnimationDuration = contentOffsetAnimationDuration 42 | self.pagingFriction = pagingFriction 43 | } 44 | 45 | static var half = Speed( 46 | layerSpeed: 0.5, 47 | decelerationRate: 0.998, 48 | contentOffsetAnimationDuration: 0.6, 49 | pagingFriction: 0.9413437171 50 | ) 51 | } 52 | 53 | static let speed = Speed() // Speed.half 54 | 55 | struct Image { 56 | 57 | static let blur = CGFloat(4) 58 | static let background = SwiftUI.Image("WorldMap") 59 | static let opaqueBackground = SwiftUI.Image("Opaque World Map") 60 | static let opaqueBackgroundWithBlur = SwiftUI.Image("Opaque World Map with Blur") 61 | } 62 | 63 | struct Color { 64 | 65 | static let foreground = SwiftUI.Color("Foreground") 66 | static let gray = SwiftUI.Color("Gray") 67 | static let mediumGray = SwiftUI.Color("Medium Gray") 68 | static let darkGray = SwiftUI.Color("Dark Gray") 69 | static let background = SwiftUI.Color("Background") 70 | static let green = SwiftUI.Color("Green") 71 | } 72 | } 73 | 74 | 75 | extension Text { 76 | 77 | func heroStyle(black: Bool = true) -> some View { 78 | self 79 | .font(Font.custom(black ? "Lato-Black" : "Lato-Regular", size: 60)) 80 | .foregroundColor(UI.Color.foreground) 81 | } 82 | 83 | func titleStyle() -> some View { 84 | self 85 | .font(Font.custom("Lato-Light", size: 36)) 86 | .foregroundColor(UI.Color.foreground) 87 | } 88 | 89 | func subtitleStyle() -> Self { 90 | self 91 | .font(Font.custom("Lato-Regular", size: 24)) 92 | .foregroundColor(UI.Color.foreground) 93 | } 94 | 95 | func largeStyle() -> some View { 96 | self 97 | .font(Font.custom("Lato-Regular", size: 20)) 98 | .foregroundColor(UI.Color.foreground) 99 | .opacity(0.4) 100 | } 101 | 102 | func regularStyle() -> Self { 103 | self 104 | .font(Font.custom("Lato-Regular", size: 14)) 105 | .foregroundColor(UI.Color.gray) 106 | } 107 | 108 | func smallStyle() -> Self { 109 | self 110 | .font(Font.custom("Lato-Regular", size: 10)) 111 | .foregroundColor(UI.Color.gray) 112 | } 113 | } 114 | 115 | 116 | extension Text { 117 | 118 | func attributeStyle() -> Self { 119 | self 120 | .font(Font.custom("Lato-Regular", size: 14)) 121 | .foregroundColor(UI.Color.green) 122 | } 123 | 124 | func valueStyle() -> some View { 125 | self 126 | .font(Font.custom("Lato-Regular", size: 14)) 127 | .frame(maxHeight: 14) 128 | } 129 | 130 | func unitStyle() -> some View { 131 | self 132 | .font(Font.custom("Lato-Thin", size: 10)) 133 | .frame(maxHeight: 12) 134 | } 135 | } 136 | 137 | 138 | extension Image { 139 | 140 | func backgroundStyle() -> some View { 141 | self 142 | .opacity(0.2) 143 | } 144 | 145 | func heroStyle() -> some View { 146 | self 147 | .font(.system(size: 72)) 148 | .foregroundColor(Color("Green")) 149 | } 150 | 151 | func iconStyle() -> some View { 152 | self 153 | .font(.system(size: 24)) 154 | .foregroundColor(Color("Green")) 155 | } 156 | } 157 | 158 | 159 | extension View { 160 | 161 | func featuredBackgroundStyle() -> some View { 162 | self 163 | .background( 164 | UI.Color.gray.opacity(0.5) 165 | .overlay( 166 | LinearGradient( 167 | gradient: Gradient( 168 | colors: [ 169 | .clear, 170 | UI.Color.green.opacity(0.2) 171 | ] 172 | ), 173 | startPoint: UnitPoint(x: 0, y: 0.7), 174 | endPoint: UnitPoint(x: 0, y: 1.0) 175 | ) 176 | ) 177 | ) 178 | } 179 | 180 | fileprivate func listBackgroundStyle() -> some View { 181 | self 182 | .background( 183 | LinearGradient( 184 | gradient: Gradient( 185 | colors: [ 186 | UI.Color.darkGray, 187 | UI.Color.mediumGray 188 | ] 189 | ), 190 | startPoint: .top, 191 | endPoint: .bottom 192 | ) 193 | .opacity(0.5) 194 | ) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_RefreshUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_RefreshUITests/SwiftUI_Pull_to_RefreshUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI_Pull_to_RefreshUITests.swift 3 | // SwiftUI_Pull_to_RefreshUITests 4 | // 5 | // Created by Geri Borbás on 2020. 07. 25.. 6 | // 7 | 8 | import XCTest 9 | import CoreGraphics 10 | 11 | class SwiftUI_Pull_to_RefreshUITests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | disableQuiescenceWaiting() 15 | } 16 | 17 | func testRefreshControlAfterScrollOffScreen() throws { 18 | 19 | // App. 20 | let app = XCUIApplication() 21 | app.launch() 22 | 23 | // Wait 2 seconds. 24 | app.press(forDuration: 2) 25 | 26 | // Pull to refresh. 27 | app.row("Row 1").drag(to: CGVector(dx: 0, dy: 10)) 28 | 29 | // Refresh control should be appeared. 30 | XCTAssertTrue( 31 | app.refreshControl.waitForExistence(timeout: 2), 32 | "Refresh control should be appeared." 33 | ) 34 | 35 | // Scroll off-screen. 36 | let dragAmount = 20 37 | app.row("Row 1").drag(to: CGVector(dx: 0, dy: -dragAmount)) 38 | app.row("Row 10").drag(to: CGVector(dx: 0, dy: dragAmount)) 39 | 40 | // Pull to refresh. 41 | app.row("Row 1").drag(to: CGVector(dx: 0, dy: 10)) 42 | 43 | // Refresh control should be disappeared (within 1 seconds). 44 | XCTAssertTrue( 45 | app.refreshControl.waitForNonExistence(timeout: 2), 46 | "Refresh control should be disappeared (within 1 seconds)." 47 | ) 48 | } 49 | } 50 | 51 | extension XCUIApplication { 52 | 53 | func row(_ name: String) -> XCUIElement { 54 | self.tables.cells[name].children(matching: .other).element(boundBy: 0) 55 | } 56 | 57 | var refreshControl: XCUIElement { 58 | self.otherElements["RefreshControl"] 59 | } 60 | } 61 | 62 | extension XCUIElement { 63 | 64 | func drag(to vector: CGVector) { 65 | self.coordinate(withNormalizedOffset: CGVector()) 66 | .press( 67 | forDuration: 0, 68 | thenDragTo: self.coordinate(withNormalizedOffset: vector) 69 | ) 70 | } 71 | 72 | func waitForNonExistence(timeout: TimeInterval) -> Bool { 73 | return XCTWaiter() 74 | .wait( 75 | for: [ 76 | XCTNSPredicateExpectation( 77 | predicate: NSPredicate(format: "exists == false"), 78 | object: self) 79 | ], 80 | timeout: timeout 81 | ) == .completed 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /SwiftUI_Pull_to_RefreshUITests/XCTestCase+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+Extensions.swift 3 | // SwiftUI_Pull_to_RefreshUITests 4 | // 5 | // Created by Geri Borbás on 2020. 07. 27.. 6 | // 7 | 8 | import XCTest 9 | 10 | extension XCTestCase { 11 | 12 | static var disabledQuiescenceWaiting = false 13 | 14 | /// Swizzle `XCUIApplicationProcess.waitForQuiescenceIncludingAnimationsIdle(:)` 15 | /// to empty method. Invoke at `setUp()`/`setUpWithError()` of your test case. 16 | func disableQuiescenceWaiting() { 17 | 18 | // Only if not disabled yet. 19 | guard Self.disabledQuiescenceWaiting == false else { return } 20 | 21 | // Swizzle. 22 | if 23 | let `class`: AnyClass = objc_getClass("XCUIApplicationProcess") as? AnyClass, 24 | let quiescenceWaitingMethod = class_getInstanceMethod(`class`, Selector(("waitForQuiescenceIncludingAnimationsIdle:"))), 25 | let emptyMethod = class_getInstanceMethod(type(of: self), #selector(Self.empty)) 26 | { 27 | method_exchangeImplementations(quiescenceWaitingMethod, emptyMethod) 28 | Self.disabledQuiescenceWaiting = true 29 | } 30 | } 31 | 32 | @objc func empty() { 33 | return 34 | } 35 | } 36 | --------------------------------------------------------------------------------