Skip to content

Instantly share code, notes, and snippets.

@tgrapperon
Last active May 20, 2024 15:27
Show Gist options
  • Save tgrapperon/034069d6116ff69b6240265132fd9ef7 to your computer and use it in GitHub Desktop.
Save tgrapperon/034069d6116ff69b6240265132fd9ef7 to your computer and use it in GitHub Desktop.
import SwiftUI
struct ContentView: View {
@State var path: [String] = []
func navigationButton(value: String) -> some View {
NavigationButton {
path.append(value)
} label: {
Text("NavigationButton")
}
.environment(\.isNavigationActive, path.last == value)
}
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("NavigationLink", value: "NavigationLink")
navigationButton(value: "NavigationButton In List")
}
.navigationDestination(for: String.self) { value in
Text(value)
}
.safeAreaInset(edge: .top) {
HStack {
navigationButton(value: "NavigationButton")
.padding()
navigationButton(value: "Styled NavigationButton")
.buttonStyle(.borderedProminent)
.padding()
}
}
}
}
}
public struct NavigationButton<Label: View>: View {
let action: () -> Void
let label: Label
public init(
action: @escaping () -> Void,
@ViewBuilder label: () -> Label
) {
self.action = action
self.label = label()
}
@Environment(\.colorScheme) var colorScheme
@Environment(\.isNavigationActive) var isNavigationActive
@State var isInList: Bool = false
@State var isPressedInList: Bool = false
var isSelectedInList: Bool { isInList && (isNavigationActive || isPressedInList) }
public var body: some View {
Button(action: action) {
HStack {
label
if isInList {
Spacer()
_DisclosureIndicator()
.foregroundColor(colorScheme == .dark ? .white : .black)
}
}
.applyIf(isInList) {
$0
// We remove the accent tint
.tint(.primary)
// Since we'll set a ButtonStyle in List, we'll lose the extended
// touch area, so we need to compensate:
.frame(maxWidth: .infinity)
.contentShape(Rectangle().inset(by: -64))
}
}
.applyIf(isInList) { // `buttonStyle` of buttons that are not in a List is preserved
$0.buttonStyle(ListButtonStyle(isPressed: $isPressedInList))
}
.listRowBackground(
ListRowBackground(isSelected: isSelectedInList)
.onAppear { isInList = true }
)
}
// This view provides a workaround to animate `listRowBackground`
struct ListRowBackground: View {
let isSelected: Bool
// We need to set this value asynchronously to get animations in `listRowBackground`
@State var isSelected_Render: Bool = false
// Plaform dependent values
var normalListBackgroundColor: Color {
Color(uiColor: .systemBackground)
}
var selectedListBackgroundColor: Color {
Color(uiColor: .systemGray4)
}
var body: some View {
ZStack {
if isSelected_Render {
selectedListBackgroundColor
} else {
normalListBackgroundColor
}
}
.animation(
// We only want to animate release
isSelected_Render ? nil : .easeOut(duration: 0.2),
value: isSelected_Render
)
.onChange(of: isSelected) { isSelected in
DispatchQueue.main.async {
isSelected_Render = isSelected
}
}
}
}
}
struct ListButtonStyle: ButtonStyle {
var isPressed: Binding<Bool>
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed) {
isPressed.wrappedValue = $0
}
}
}
extension View {
// We know why we use this and its limitations
@ViewBuilder
func applyIf<Modified>(
_ predicate: @autoclosure () -> Bool,
apply modifier: (Self) -> Modified
) -> some View where Modified: View {
if predicate() {
modifier(self)
} else {
self
}
}
}
struct NavigationIsActiveKey: EnvironmentKey {
static var defaultValue: Bool { false }
}
extension EnvironmentValues {
var isNavigationActive: Bool {
get { self[NavigationIsActiveKey.self] }
set { self[NavigationIsActiveKey.self] = newValue }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment