Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions Sources/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ struct RootView: View {
systemOpenURL(url)
}
}
// Route inline comment/text links the same way.
// Route inline comment/text links the same way, honoring reader mode.
.environment(\.openURL, OpenURLAction { url in
if settings.openLinksInApp {
linkOpener.present(url, reader: false)
linkOpener.present(url, reader: settings.readerMode)
return .handled
}
return .systemAction
Expand Down
76 changes: 75 additions & 1 deletion Sources/Features/Feed/FeedView.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
import SwiftUI
import UIKit

/// Scroll position plus the current top content inset, observed together so the
/// nav-bar height can be derived from the inset.
private struct ScrollInfo: Equatable {
var scrolled: CGFloat
var insetTop: CGFloat
}

/// The primary feeds screen: a pinned feed selector over a paginated story list.
/// The Ember wordmark fades + lifts and the selector eases up as you scroll; the
/// nav bar collapses to reclaim the wordmark's space once it's gone.
struct FeedView: View {
@State private var vm = FeedViewModel()
@State private var path = NavigationPath()
@State private var logoHidden = false
@State private var logoOpacity: CGFloat = 1
@State private var logoOffset: CGFloat = 0
@State private var pickerOffset: CGFloat = 0
/// Real nav-bar height, derived as `inset − statusBar − pickerHeight`, computed
/// before the first scroll so the list doesn't jump on first use.
@State private var navBar: CGFloat = 44
@State private var pickerHeight: CGFloat = 0
@State private var shownInsetTop: CGFloat = 0

/// Picker rises at half the wordmark's rate → over 2× the scroll.
private static let pickerRate: CGFloat = 0.5

/// Status-bar inset from the key window (the nav bar isn't part of this).
private var statusBarTop: CGFloat {
(UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.safeAreaInsets.top) ?? 0
}

private func updateNavBar() {
guard shownInsetTop > 0, pickerHeight > 0 else { return }
let measured = shownInsetTop - statusBarTop - pickerHeight
if measured > 1 { navBar = measured }
}

@Environment(SettingsStore.self) private var settings
@Environment(BookmarkStore.self) private var bookmarks
Expand All @@ -13,6 +50,10 @@ struct FeedView: View {
content
.navigationTitle("Ember")
.navigationBarTitleDisplayMode(.inline)
.toolbar(logoHidden ? .hidden : .visible, for: .navigationBar)
// One continuous bar: the picker's `.bar` extends up behind the
// wordmark, so there's no second bar to fade or seam.
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 6) {
Expand All @@ -23,6 +64,8 @@ struct FeedView: View {
.font(.system(.headline, design: .rounded).weight(.bold))
.foregroundStyle(Theme.textPrimary)
}
.opacity(logoOpacity)
.offset(y: logoOffset)
.accessibilityAddTraits(.isHeader)
.accessibilityLabel("Ember")
}
Expand All @@ -32,6 +75,8 @@ struct FeedView: View {
Haptics.selection()
Task { await vm.switchTo(feed) }
}
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { pickerHeight = $0; updateNavBar() }
.offset(y: pickerOffset)
}
.navigationDestination(for: HNItem.self) { StoryDetailView(item: $0) }
.navigationDestination(for: UserRoute.self) { UserView(username: $0.username) }
Expand Down Expand Up @@ -68,7 +113,8 @@ struct FeedView: View {
ZStack {
// Hide the default disclosure chevron for a cleaner row.
NavigationLink(value: story) { EmptyView() }.opacity(0)
StoryRow(item: story, rank: index + 1)
StoryRow(item: story, rank: index + 1,
onSelectUser: { path.append(UserRoute(username: $0)) })
}
.listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l))
.listRowSeparatorTint(Theme.separator)
Expand Down Expand Up @@ -102,6 +148,34 @@ struct FeedView: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Theme.background)
.onScrollGeometryChange(for: ScrollInfo.self) { geo in
ScrollInfo(scrolled: geo.contentOffset.y + geo.contentInsets.top,
insetTop: geo.contentInsets.top)
} action: { _, info in
let scrolled = info.scrolled
if !logoHidden {
shownInsetTop = info.insetTop
updateNavBar()
}
// Fade + lift the wordmark continuously; compensate for the inset jump
// while hidden so it tracks the true visual position across the toggle.
let visualScroll = scrolled + (logoHidden ? navBar : 0)
let progress = max(0, min(1, visualScroll / navBar))
logoOpacity = max(0.001, 1 - progress)
logoOffset = -progress * navBar
// Picker eases up at half rate; offset compensated once hidden so it
// stays put across the (instant) collapse.
let pickerRise = max(0, min(navBar, visualScroll * Self.pickerRate))
pickerOffset = logoHidden ? (navBar - pickerRise) : -pickerRise
// Collapse instantly. Un-collapse a bar-height from the top so the
// offset never goes positive (no gap above the picker); the wide
// hide/show gap prevents flapping.
if !logoHidden, scrolled > navBar * 2.2 {
logoHidden = true
} else if logoHidden, scrolled < navBar {
logoHidden = false
}
}
.refreshable { await vm.reload() }
}
}
Expand Down
31 changes: 21 additions & 10 deletions Sources/Features/Onboarding/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ struct OnboardingView: View {
ToggleRow(title: "Rank numbers", subtitle: "Show numeric position in feeds", systemImage: "number", isOn: settings.showRankNumbers)
Divider().padding(.leading, 40)
ToggleRow(title: "Story thumbnails", subtitle: "Show the site favicon next to each story", systemImage: "square.fill.text.grid.1x2", isOn: settings.showThumbnails)
Divider().padding(.leading, 40)
ToggleRow(title: "URL above title", subtitle: "Show the site link above each headline, classic HN style", systemImage: "link", isOn: settings.urlAboveTitle)
}
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: Radius.m, style: .continuous))
Expand Down Expand Up @@ -383,7 +385,7 @@ struct OnboardingView: View {
.font(.system(size: 10, weight: .heavy))
.tracking(1)
.foregroundStyle(Theme.textTertiary)
OnboardingPreviewRow(accent: settings.accent.color, showThumbnail: settings.showThumbnails, showRank: settings.showRankNumbers, underlineLinks: settings.underlineLinks)
OnboardingPreviewRow(accent: settings.accent.color, showThumbnail: settings.showThumbnails, showRank: settings.showRankNumbers, underlineLinks: settings.underlineLinks, urlAboveTitle: settings.urlAboveTitle)
.padding(Spacing.m)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: Radius.m, style: .continuous))
Expand Down Expand Up @@ -608,16 +610,25 @@ private struct OnboardingPreviewRow: View {
var showThumbnail: Bool = true
var showRank: Bool = true
var underlineLinks: Bool = true
var urlAboveTitle: Bool = false

private var hostLabel: some View {
Text("paulgraham.com")
.font(AppFont.meta)
.foregroundStyle(Theme.textSecondary)
}

var body: some View {
HStack(alignment: .top, spacing: Spacing.m) {
if showRank {
// Mirror StoryRow: when the URL sits above the title, shrink the
// rank and top-align it so they share the first line.
Text("42")
.font(.system(.footnote, design: .rounded).weight(.bold))
.font(urlAboveTitle ? AppFont.meta : .system(.footnote, design: .rounded).weight(.bold))
.monospacedDigit()
.foregroundStyle(Theme.textTertiary)
.frame(width: 22, alignment: .trailing)
.padding(.top, 2)
.padding(.top, urlAboveTitle ? 0 : 2)
}
if showThumbnail {
RoundedRectangle(cornerRadius: 9, style: .continuous)
Expand All @@ -626,19 +637,19 @@ private struct OnboardingPreviewRow: View {
.overlay(Image(systemName: "flame.fill").foregroundStyle(accent))
}
VStack(alignment: .leading, spacing: 5) {
if urlAboveTitle { hostLabel }
Text("How to Do Great Work")
.font(AppFont.storyTitle)
.foregroundStyle(Theme.textPrimary)
Text("paulgraham.com")
.font(AppFont.meta)
.foregroundStyle(Theme.textSecondary)
if !urlAboveTitle { hostLabel }
HStack(spacing: Spacing.m) {
StatLabel(systemImage: "arrow.up", value: "842", tint: accent)
StatLabel(systemImage: "arrow.up", value: "842", tint: Theme.upvote)
StatLabel(systemImage: "bubble.left", value: "312")
Text("by pg · 1h")
.font(AppFont.meta)
.foregroundStyle(Theme.textSecondary)
Text("pg")
Text("1h")
}
.font(AppFont.meta)
.foregroundStyle(Theme.textSecondary)
Text("paulgraham.com/greatwork.html")
.font(AppFont.meta)
.foregroundStyle(accent)
Expand Down
3 changes: 2 additions & 1 deletion Sources/Features/Saved/SavedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ struct SavedView: View {
@ViewBuilder private func row(_ story: HNItem, @ViewBuilder swipe: () -> some View) -> some View {
ZStack {
NavigationLink(value: story) { EmptyView() }.opacity(0)
StoryRow(item: story)
StoryRow(item: story,
onSelectUser: { path.append(UserRoute(username: $0)) })
}
.listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l))
.listRowSeparatorTint(Theme.separator)
Expand Down
3 changes: 2 additions & 1 deletion Sources/Features/Search/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ struct SearchView: View {
ForEach(vm.results) { story in
ZStack {
NavigationLink(value: story) { EmptyView() }.opacity(0)
StoryRow(item: story)
StoryRow(item: story,
onSelectUser: { path.append(UserRoute(username: $0)) })
}
.listRowInsets(EdgeInsets(top: 0, leading: Spacing.l, bottom: 0, trailing: Spacing.l))
.listRowSeparatorTint(Theme.separator)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ struct SettingsView: View {
Label("Open Links in App", systemImage: "safari")
}
Toggle(isOn: settings.readerMode) {
Label("Use Reader When Available", systemImage: "doc.plaintext")
Label("Open Links in Reader Mode", systemImage: "doc.plaintext")
}
.disabled(!settings.wrappedValue.openLinksInApp)
Toggle(isOn: settings.markReadOnOpen) {
Expand All @@ -134,6 +134,9 @@ struct SettingsView: View {
Toggle(isOn: settings.showThumbnails) {
Label("Show Story Thumbnails", systemImage: "square.fill.text.grid.1x2")
}
Toggle(isOn: settings.urlAboveTitle) {
Label("Show URL Above Title", systemImage: "link")
}
}
}

Expand Down
Loading