Skip to content

Commit

Permalink
Update navigation bar style based on SwiftUI content offset
Browse files Browse the repository at this point in the history
  • Loading branch information
elaine-signal authored Nov 13, 2024
1 parent 7f76a8f commit 66e4548
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 5 deletions.
14 changes: 13 additions & 1 deletion Signal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,7 @@
B93296652BB5CF3200B8BD39 /* NicknameRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93296642BB5CF3200B8BD39 /* NicknameRecord.swift */; };
B93296672BB5CF7500B8BD39 /* NicknameRecordStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93296662BB5CF7500B8BD39 /* NicknameRecordStore.swift */; };
B93296692BBB3FF200B8BD39 /* ProfileName.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95BBAC12BB36025009EFB4A /* ProfileName.swift */; };
B9488E752CDED27200C1294B /* ScrollOffset.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9488E742CDED27200C1294B /* ScrollOffset.swift */; };
B95A765C2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95A765B2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift */; };
B95A765E2B76E93500AA7E97 /* FindByUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */; };
B96D6D792B9F83270039EB99 /* SignalSymbols-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */; };
Expand Down Expand Up @@ -5415,6 +5416,7 @@
B9327B3D2BBCC2EB00CCDBBA /* MockNicknameManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNicknameManager.swift; sourceTree = "<group>"; };
B93296642BB5CF3200B8BD39 /* NicknameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameRecord.swift; sourceTree = "<group>"; };
B93296662BB5CF7500B8BD39 /* NicknameRecordStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameRecordStore.swift; sourceTree = "<group>"; };
B9488E742CDED27200C1294B /* ScrollOffset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollOffset.swift; sourceTree = "<group>"; };
B95A765B2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarViewPresentationContextProvider.swift; sourceTree = "<group>"; };
B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindByUsernameViewController.swift; sourceTree = "<group>"; };
B95BBAC12BB36025009EFB4A /* ProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileName.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7903,11 +7905,11 @@
34A954D2271B4F3E00B05242 /* Appearance */ = {
isa = PBXGroup;
children = (
B9488E732CDED25900C1294B /* SwiftUI */,
34A954D8271B4F3E00B05242 /* ColorOrGradient+SignalUI.swift */,
34A954DA271B4F3E00B05242 /* ColorOrGradientSwatchView.swift */,
34A95506271B510400B05242 /* ConversationStyle.swift */,
34A954D6271B4F3E00B05242 /* GroupNameColors.swift */,
B9E322E62CD170ED006DAF3B /* SignalList.swift */,
B9F817632BA263A900EAEE23 /* SignalSymbols.swift */,
34A954D4271B4F3E00B05242 /* Theme+Icons.swift */,
7677E40C29F75C4200AC6A75 /* Theme.swift */,
Expand Down Expand Up @@ -10962,6 +10964,15 @@
path = Contacts;
sourceTree = "<group>";
};
B9488E732CDED25900C1294B /* SwiftUI */ = {
isa = PBXGroup;
children = (
B9488E742CDED27200C1294B /* ScrollOffset.swift */,
B9E322E62CD170ED006DAF3B /* SignalList.swift */,
);
path = SwiftUI;
sourceTree = "<group>";
};
B99B155B2A71B9F300E26DAC /* Stories */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -15937,6 +15948,7 @@
7677E41129F7A60500AC6A75 /* ScreenLockViewController.swift in Sources */,
05594CCE2C989F1900CCBFF6 /* ScrollableWhenCompact.swift in Sources */,
0510F69E2C91EB3000FA3FDE /* ScrollBounceBehaviorIfAvailable.swift in Sources */,
B9488E752CDED27200C1294B /* ScrollOffset.swift in Sources */,
50597BBF2B97D629004681E1 /* SearchableNameFinder.swift in Sources */,
66FC638E29EDABAC00F00DAC /* SearchDisplayConfigurations.swift in Sources */,
66FBC4E328DA82AA00BD9E8B /* SelectMyStoryRecipientsViewController.swift in Sources */,
Expand Down
109 changes: 109 additions & 0 deletions SignalUI/Appearance/SwiftUI/ScrollOffset.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SwiftUI

// MARK: - Scroll Anchor

public struct ScrollAnchor: Equatable {
public let topAnchor: Anchor<CGPoint>
public let correction: CGFloat
}

public struct ScrollAnchorPreferenceKey: PreferenceKey {
public typealias Value = [ScrollAnchor]

public static var defaultValue: Value = []

public static func reduce(value: inout Value, nextValue: () -> Value) {
// We can't determine which one anchor we want without a GeometryProxy
value.append(contentsOf: nextValue())
}
}

public struct ProvideScrollAnchor: ViewModifier {
var correction: CGFloat

public func body(content: Content) -> some View {
content
.transformAnchorPreference(
key: ScrollAnchorPreferenceKey.self,
value: .top
) { (key, anchor) in
key.append(ScrollAnchor(topAnchor: anchor, correction: correction))
}
}
}

extension View {
/// Apply to the top-most element in a scroll view
/// (`ScrollView`, `List`, `Form`) to which ``readScrollOffset()``
/// has been applied to read the scroll offset.
///
/// If this is applied to multiple subviews within a scroll view,
/// the highest value is used in `readScrollOffset`.
public func provideScrollAnchor(correction: CGFloat = 0) -> some View {
self.modifier(ProvideScrollAnchor(correction: correction))
}
}

// MARK: - ScrollOffset

public struct ScrollOffsetPreferenceKey: PreferenceKey {
public static var defaultValue: CGFloat = -.infinity

public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}

public struct ScrollOffsetReader: ViewModifier {
@State private var scrollOffset: CGFloat = 0

public func body(content: Content) -> some View {
GeometryReader { geometry in
content
.onPreferenceChange(ScrollAnchorPreferenceKey.self) { anchors in
scrollOffset = anchors.map { scrollAnchor in
-(geometry[scrollAnchor.topAnchor].y + scrollAnchor.correction)
}.max() ?? 0
}
}
.preference(key: ScrollOffsetPreferenceKey.self, value: scrollOffset)
}
}

extension View {
/// Apply to a scroll view (`ScrollView`, `List`, `Form`) to
/// have it read scroll anchors in the content applied with
/// ``provideScrollAnchor(correction:)`` and report the
/// scroll view offset in ``ScrollOffsetPreferenceKey``.
///
/// Note that if `provideScrollAnchor` is applied to multiple views in the
/// scrolling content (as is done with ``SignalSection``), this will return
/// the highest value read. If the content is
/// loaded lazily (as is done with ``SignalList``), this will report the
/// offset of the highest currently-rendered item, which may not reflect the
/// total scroll distance.
public func readScrollOffset() -> some View {
self.modifier(ScrollOffsetReader())
}
}

#Preview {
NavigationView {
SignalList {
SignalSection {
ForEach(0..<50) {
Text(verbatim: "Item \($0)")
}
}
}
.navigationTitle(Text(verbatim: "Title text"))
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { scrollOffset in
print(scrollOffset)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

public import SwiftUI

// MARK: - SignalList

public struct SignalList<Content: View>: View {
private var content: Content

Expand All @@ -16,6 +18,7 @@ public struct SignalList<Content: View>: View {
List {
content
}
.readScrollOffset()
.listStyle(.insetGrouped)
}

Expand All @@ -30,6 +33,8 @@ public struct SignalList<Content: View>: View {
}
}

// MARK: - SignalSection

public struct SignalSection<Content: View, Header: View, Footer: View>: View {

private enum Components {
Expand Down Expand Up @@ -73,7 +78,9 @@ public struct SignalSection<Content: View, Header: View, Footer: View>: View {
switch components {
case let .contentHeaderFooter(content, header, footer):
Section {
content
ContentView {
content
}
} header: {
HeaderView {
header
Expand All @@ -83,25 +90,46 @@ public struct SignalSection<Content: View, Header: View, Footer: View>: View {
}
case let .contentHeader(content, header):
Section {
content
ContentView {
content
}
} header: {
HeaderView {
header
}
}
case let .contentFooter(content, footer):
Section {
content
ContentView {
content
}
} footer: {
footer
}
case let .content(content):
Section {
content
ContentView {
content
}
}
}
}

private struct ContentView<C: View>: View {
private let content: C

init(@ViewBuilder content: () -> C) {
self.content = content()
}

var body: some View {
content
// The table cells have a top margin of 12, so the top of
// the cell is 12 points above the top of the content.
.provideScrollAnchor(correction: -12)
}
}

private struct HeaderView<C: View>: View {
private let content: C

Expand All @@ -115,10 +143,13 @@ public struct SignalSection<Content: View, Header: View, Footer: View>: View {
.textCase(.none)
.font(.headline)
.foregroundStyle(.primary)
.provideScrollAnchor(correction: 4)
}
}
}

// MARK: - Previews

@available(iOS 18.0, *)
#Preview {
SignalList {
Expand Down
32 changes: 32 additions & 0 deletions SignalUI/SwiftUIExtensions/HostingController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ extension EnvironmentValues {
/// environment, allowing SwiftUI views to explicitly control whether animations
/// are performed during a navigation transition, or after completion.
open class HostingController<Wrapped: View>: UIHostingController<_HostingControllerWrapperView<Wrapped>> {

private var scrollOffset: CGFloat = 0 {
didSet {
let scrollOffsetDidFlip = scrollOffset * oldValue <= 0
if scrollOffsetDidFlip {
owsNavigationController?.updateNavbarAppearance(animated: true)
}
}
}

public init(wrappedView: Wrapped) {
super.init(rootView: _HostingControllerWrapperView(wrappedView: wrappedView))
}
Expand All @@ -47,6 +57,10 @@ open class HostingController<Wrapped: View>: UIHostingController<_HostingControl
open override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

rootView.scrollOffsetDidChange = { [weak self] scrollOffset in
self?.scrollOffset = scrollOffset
}

rootView.appearanceTransitionState = .appearing

if let transitionCoordinator {
Expand All @@ -71,12 +85,30 @@ open class HostingController<Wrapped: View>: UIHostingController<_HostingControl
}
}

extension HostingController: OWSNavigationChildController {
private var usesSolidNavbarStyle: Bool {
scrollOffset <= 0
}

public var preferredNavigationBarStyle: OWSNavigationBarStyle {
usesSolidNavbarStyle ? .solid : .blur
}

public var navbarBackgroundColorOverride: UIColor? {
usesSolidNavbarStyle ? UIColor.Signal.groupedBackground : nil
}
}

public struct _HostingControllerWrapperView<Wrapped: View>: View {
fileprivate var wrappedView: Wrapped
fileprivate var appearanceTransitionState: HostingControllerAppearanceTransitionState?
fileprivate var scrollOffsetDidChange: ((CGFloat) -> Void)?

public var body: some View {
wrappedView
.environment(\.appearanceTransitionState, appearanceTransitionState)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { scrollOffset in
scrollOffsetDidChange?(scrollOffset)
}
}
}

0 comments on commit 66e4548

Please sign in to comment.