From 7dee82aad01da729768a966064d4524c8b37f720 Mon Sep 17 00:00:00 2001 From: Ivan Vorobei Date: Tue, 26 Nov 2024 16:57:00 +0300 Subject: [PATCH] Added layout guides. --- .../LayoutGuides/LayoutGuides.swift | 119 ++++++++++++++++++ .../LayoutGuidesEnvironment.swift | 22 ++++ .../LayoutGuidesObserverView.swift | 91 ++++++++++++++ .../Mimicrate/ModalSheet/ModalSheet.swift | 18 +++ 4 files changed, 250 insertions(+) create mode 100644 Sources/SwiftUIExtension/LayoutGuides/LayoutGuides.swift create mode 100644 Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesEnvironment.swift create mode 100644 Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesObserverView.swift diff --git a/Sources/SwiftUIExtension/LayoutGuides/LayoutGuides.swift b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuides.swift new file mode 100644 index 0000000..4d09fca --- /dev/null +++ b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuides.swift @@ -0,0 +1,119 @@ +import SwiftUI + +extension View { + + public func fitToReadableContentWidth(alignment: Alignment = .center) -> some View { + self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .readableContent)) + } + + public func fitToLayoutMarginsWidth(alignment: Alignment = .center) -> some View { + self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .layoutMargins)) + } + + public func measureLayoutGuides() -> some View { + self.modifier(LayoutGuidesModifier()) + } +} + +public struct WithLayoutMargins: View where Content: View { + + let content: (EdgeInsets) -> Content + + public init(@ViewBuilder content: @escaping (EdgeInsets) -> Content) { + self.content = content + } + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = { _ in content() } + } + + public var body: some View { + InsetContent(content: content) + .measureLayoutGuides() + } + + private struct InsetContent: View { + + let content: (EdgeInsets) -> Content + + @Environment(\.layoutMarginsInsets) var layoutMarginsInsets + + var body: some View { + content(layoutMarginsInsets) + } + } +} + +// MARK: - Private + +internal struct FitLayoutGuidesWidth: ViewModifier { + + enum Kind { + case layoutMargins + case readableContent + } + + let alignment: Alignment + let kind: Kind + + func body(content: Content) -> some View { + switch kind { + case .layoutMargins: + content.modifier(InsetLayoutMargins(alignment: alignment)) + .measureLayoutGuides() + case .readableContent: + content.modifier(InsetReadableContent(alignment: alignment)) + .measureLayoutGuides() + } + } + + private struct InsetReadableContent: ViewModifier { + + let alignment: Alignment + @Environment(\.readableContentInsets) var readableContentInsets + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.leading, readableContentInsets.leading) + .padding(.trailing, readableContentInsets.trailing) + } + } + + private struct InsetLayoutMargins: ViewModifier { + + let alignment: Alignment + @Environment(\.layoutMarginsInsets) var layoutMarginsInsets + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.leading, layoutMarginsInsets.leading) + .padding(.trailing, layoutMarginsInsets.trailing) + } + } +} + +internal struct LayoutGuidesModifier: ViewModifier { + + @State var layoutMarginsInsets: EdgeInsets = .init() + @State var readableContentInsets: EdgeInsets = .init() + + func body(content: Content) -> some View { + content + #if os(iOS) || os(tvOS) + .environment(\.layoutMarginsInsets, layoutMarginsInsets) + .environment(\.readableContentInsets, readableContentInsets) + .background( + LayoutGuidesObserverView( + onLayoutMarginsGuideChange: { + layoutMarginsInsets = $0 + }, + onReadableContentGuideChange: { + readableContentInsets = $0 + }) + ) + #endif + } +} + diff --git a/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesEnvironment.swift b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesEnvironment.swift new file mode 100644 index 0000000..67f7119 --- /dev/null +++ b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesEnvironment.swift @@ -0,0 +1,22 @@ +import SwiftUI + +private struct LayoutMarginsGuidesKey: EnvironmentKey { + static var defaultValue: EdgeInsets { .init() } +} + +private struct ReadableContentGuidesKey: EnvironmentKey { + static var defaultValue: EdgeInsets { .init() } +} + +extension EnvironmentValues { + + public var layoutMarginsInsets: EdgeInsets { + get { self[LayoutMarginsGuidesKey.self] } + set { self[LayoutMarginsGuidesKey.self] = newValue } + } + + public var readableContentInsets: EdgeInsets { + get { self[ReadableContentGuidesKey.self] } + set { self[ReadableContentGuidesKey.self] = newValue } + } +} diff --git a/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesObserverView.swift b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesObserverView.swift new file mode 100644 index 0000000..ec0749d --- /dev/null +++ b/Sources/SwiftUIExtension/LayoutGuides/LayoutGuidesObserverView.swift @@ -0,0 +1,91 @@ +#if os(iOS) || os(tvOS) +import UIKit +import SwiftUI + +struct LayoutGuidesObserverView: UIViewRepresentable { + + let onLayoutMarginsGuideChange: (EdgeInsets) -> Void + let onReadableContentGuideChange: (EdgeInsets) -> Void + + func makeUIView(context: Context) -> LayoutGuidesView { + let uiView = LayoutGuidesView() + uiView.onLayoutMarginsGuideChange = onLayoutMarginsGuideChange + uiView.onReadableContentGuideChange = onReadableContentGuideChange + return uiView + } + + func updateUIView(_ uiView: LayoutGuidesView, context: Context) { + uiView.onLayoutMarginsGuideChange = onLayoutMarginsGuideChange + uiView.onReadableContentGuideChange = onReadableContentGuideChange + } + + final class LayoutGuidesView: UIView { + var onLayoutMarginsGuideChange: (EdgeInsets) -> Void = { _ in } + var onReadableContentGuideChange: (EdgeInsets) -> Void = { _ in } + + override func layoutMarginsDidChange() { + super.layoutMarginsDidChange() + updateLayoutMargins() + updateReadableContent() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateReadableContent() + } + + // `layoutSubviews` doesn't seem late enough to retrieve an up-to-date `readableContentGuide` + // in some cases, like when toggling the sidebar in a NavigationSplitView on iPad. + // It seems that observing the `frame` is enough to fix this edge case, but a better + // heuristic would be preferable. + override var frame: CGRect { + didSet { + self.updateReadableContent() + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.layoutDirection != previousTraitCollection?.layoutDirection { + updateReadableContent() + } + } + + var previousLayoutMargins: EdgeInsets? = nil + func updateLayoutMargins() { + let edgeInsets = EdgeInsets( + top: directionalLayoutMargins.top, + leading: directionalLayoutMargins.leading, + bottom: directionalLayoutMargins.bottom, + trailing: directionalLayoutMargins.trailing + ) + guard previousLayoutMargins != edgeInsets else { return } + onLayoutMarginsGuideChange(edgeInsets) + previousLayoutMargins = edgeInsets + } + + var previousReadableContentGuide: EdgeInsets? = nil + func updateReadableContent() { + let isRightToLeft = traitCollection.layoutDirection == .rightToLeft + let layoutFrame = readableContentGuide.layoutFrame + + let readableContentInsets = + UIEdgeInsets( + top: layoutFrame.minY - bounds.minY, + left: layoutFrame.minX - bounds.minX, + bottom: -(layoutFrame.maxY - bounds.maxY), + right: -(layoutFrame.maxX - bounds.maxX) + ) + let edgeInsets = EdgeInsets( + top: readableContentInsets.top, + leading: isRightToLeft ? readableContentInsets.right : readableContentInsets.left, + bottom: readableContentInsets.bottom, + trailing: isRightToLeft ? readableContentInsets.left : readableContentInsets.right + ) + guard previousReadableContentGuide != edgeInsets else { return } + onReadableContentGuideChange(edgeInsets) + previousReadableContentGuide = edgeInsets + } + } +} +#endif diff --git a/Sources/SwiftUIExtension/Views/Mimicrate/ModalSheet/ModalSheet.swift b/Sources/SwiftUIExtension/Views/Mimicrate/ModalSheet/ModalSheet.swift index dfddb0d..f9bdb02 100644 --- a/Sources/SwiftUIExtension/Views/Mimicrate/ModalSheet/ModalSheet.swift +++ b/Sources/SwiftUIExtension/Views/Mimicrate/ModalSheet/ModalSheet.swift @@ -21,5 +21,23 @@ extension View { return self.modifier(sheet) } + + public func modalSheet( + isPresented: Binding, + dismissable: Bool, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) -> some View where Content : View { + + let sheet = ModalSheetModifier( + isPresented: isPresented, + selection: .constant(""), + dismissable: dismissable, + onDismiss: onDismiss, + modalContent: content + ) + + return self.modifier(sheet) + } } #endif