mirror of
https://github.com/samuelclay/NewsBlur.git
synced 2025-09-18 21:50:56 +00:00

- Rewrote the trainer view using SwiftUI, to be native instead of a web view. - Added a Feed class and other data enhancements to support this.
210 lines
6.3 KiB
Swift
210 lines
6.3 KiB
Swift
//
|
|
// SwiftUIUtilities.swift
|
|
// NewsBlur
|
|
//
|
|
// Created by David Sinclair on 2023-02-01.
|
|
// Copyright © 2023 NewsBlur. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// Some useful SwiftUI extensions.
|
|
|
|
extension View {
|
|
@ViewBuilder
|
|
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
|
if condition {
|
|
transform(self)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
@ViewBuilder
|
|
func modify<Content: View>(@ViewBuilder _ transform: (Self) -> Content?) -> some View {
|
|
if let view = transform(self), !(view is EmptyView) {
|
|
view
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
|
clipShape(RoundedCorner(radius: radius, corners: corners))
|
|
}
|
|
}
|
|
|
|
extension Text {
|
|
func colored(_ color: Color) -> Text {
|
|
if #available(iOS 17.0, *) {
|
|
self.foregroundStyle(color)
|
|
} else {
|
|
self.foregroundColor(color)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RoundedCorner: Shape {
|
|
var radius: CGFloat = .infinity
|
|
var corners: UIRectCorner = .allCorners
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
|
return Path(path.cgPath)
|
|
}
|
|
}
|
|
|
|
extension Color {
|
|
static var random: Color {
|
|
return Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)
|
|
)
|
|
}
|
|
|
|
static func themed(_ hex: [NSNumber]) -> Color {
|
|
return Color(ThemeManager.color(fromRGB: hex))
|
|
}
|
|
}
|
|
|
|
extension NSArray {
|
|
@objc(safeObjectAtIndex:) func safeObject(at index: Int) -> Any? {
|
|
if index >= 0, index < count {
|
|
return self[index]
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// From https://www.swiftbysundell.com/articles/observing-swiftui-scrollview-content-offset/
|
|
|
|
struct PositionObservingView<Content: View>: View {
|
|
var coordinateSpace: CoordinateSpace
|
|
@Binding var position: CGPoint
|
|
@ViewBuilder var content: () -> Content
|
|
|
|
var body: some View {
|
|
content()
|
|
.background(GeometryReader { geometry in
|
|
Color.clear.preference(
|
|
key: PreferenceKey.self,
|
|
value: geometry.frame(in: coordinateSpace).origin
|
|
)
|
|
})
|
|
.onPreferenceChange(PreferenceKey.self) { position in
|
|
self.position = position
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension PositionObservingView {
|
|
struct PreferenceKey: SwiftUI.PreferenceKey {
|
|
static var defaultValue: CGPoint { .zero }
|
|
|
|
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
|
|
// No-op
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OffsetObservingScrollView<Content: View>: View {
|
|
var axes: Axis.Set = [.vertical]
|
|
var showsIndicators = true
|
|
@Binding var offset: CGPoint
|
|
@ViewBuilder var content: () -> Content
|
|
|
|
// The name of our coordinate space doesn't have to be
|
|
// stable between view updates (it just needs to be
|
|
// consistent within this view), so we'll simply use a
|
|
// plain UUID for it:
|
|
private let coordinateSpaceName = UUID()
|
|
|
|
var body: some View {
|
|
ScrollView(axes, showsIndicators: showsIndicators) {
|
|
PositionObservingView(
|
|
coordinateSpace: .named(coordinateSpaceName),
|
|
position: Binding(
|
|
get: { offset },
|
|
set: { newOffset in
|
|
offset = CGPoint(
|
|
x: -newOffset.x,
|
|
y: -newOffset.y
|
|
)
|
|
}
|
|
),
|
|
content: content
|
|
)
|
|
}
|
|
.coordinateSpace(name: coordinateSpaceName)
|
|
}
|
|
}
|
|
|
|
struct WrappingHStack<Model, V>: View where Model: Hashable, V: View {
|
|
typealias ViewGenerator = (Model) -> V
|
|
|
|
var models: [Model]
|
|
var horizontalSpacing: CGFloat = 2
|
|
var verticalSpacing: CGFloat = 0
|
|
var viewGenerator: ViewGenerator
|
|
|
|
@State private var totalHeight
|
|
= CGFloat.zero // << variant for ScrollView/List
|
|
// = CGFloat.infinity // << variant for VStack
|
|
|
|
var body: some View {
|
|
VStack {
|
|
GeometryReader { geometry in
|
|
self.generateContent(in: geometry)
|
|
}
|
|
}
|
|
.frame(height: totalHeight)// << variant for ScrollView/List
|
|
//.frame(maxHeight: totalHeight) // << variant for VStack
|
|
}
|
|
|
|
private func generateContent(in geometry: GeometryProxy) -> some View {
|
|
var width = CGFloat.zero
|
|
var height = CGFloat.zero
|
|
|
|
return ZStack(alignment: .topLeading) {
|
|
ForEach(self.models, id: \.self) { models in
|
|
viewGenerator(models)
|
|
.padding(.horizontal, horizontalSpacing)
|
|
.padding(.vertical, verticalSpacing)
|
|
.alignmentGuide(.leading, computeValue: { dimension in
|
|
if (abs(width - dimension.width) > geometry.size.width)
|
|
{
|
|
width = 0
|
|
height -= dimension.height
|
|
}
|
|
let result = width
|
|
if models == self.models.last! {
|
|
width = 0 //last item
|
|
} else {
|
|
width -= dimension.width
|
|
}
|
|
return result
|
|
})
|
|
.alignmentGuide(.top, computeValue: {dimension in
|
|
let result = height
|
|
if models == self.models.last! {
|
|
height = 0 // last item
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
}.background(viewHeightReader($totalHeight))
|
|
}
|
|
|
|
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
|
return GeometryReader { geometry -> Color in
|
|
let rect = geometry.frame(in: .local)
|
|
DispatchQueue.main.async {
|
|
binding.wrappedValue = rect.size.height
|
|
}
|
|
return .clear
|
|
}
|
|
}
|
|
}
|