Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

df/FloatingPanelEnhancements #108

Merged
merged 40 commits into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
bdb639d
Start
dfeinzimer Jul 29, 2022
60c5839
Progress
dfeinzimer Jul 29, 2022
f5952d8
Progress
dfeinzimer Jul 29, 2022
934595d
Progress
dfeinzimer Jul 29, 2022
1f34502
Update FloatingPanel.swift
dfeinzimer Jul 29, 2022
1c28a80
Progress
dfeinzimer Jul 29, 2022
61e27e7
Progress
dfeinzimer Jul 29, 2022
2bd0f95
Progress
dfeinzimer Jul 29, 2022
1647d8f
Update FloatingPanel.swift
dfeinzimer Jul 29, 2022
100febb
Update FloatingPanel.swift
dfeinzimer Jul 29, 2022
d94f9c7
Progress
dfeinzimer Jul 29, 2022
687e398
Progress
dfeinzimer Jul 29, 2022
5b775ae
Merge in latest
dfeinzimer Jul 29, 2022
2ef0f50
Default detent
dfeinzimer Jul 29, 2022
0c04f28
Progress
dfeinzimer Jul 30, 2022
0cf61b2
Add doc
dfeinzimer Jul 30, 2022
51950f3
Finish doc
dfeinzimer Jul 30, 2022
8143f08
Update FloatingPanelModifier.swift
dfeinzimer Aug 1, 2022
07a39de
Update FloatingPanel.swift
dfeinzimer Aug 1, 2022
4d6de42
Update FloatingPanel.swift
dfeinzimer Aug 1, 2022
6ff6195
Add doc
dfeinzimer Aug 1, 2022
8216d5a
Merge latest
dfeinzimer Aug 3, 2022
902f796
Add padding to content bottom in compact envs
dfeinzimer Aug 3, 2022
060d0f5
Address hidden handle
dfeinzimer Aug 4, 2022
07cff79
Simplification
dfeinzimer Aug 5, 2022
bce92eb
Update FloatingPanel.swift
dfeinzimer Aug 5, 2022
f83b293
Merge latest
dfeinzimer Aug 8, 2022
268bd8d
Merge branch 'v.next' into df/FloatingPanelEnhancements
dfeinzimer Aug 8, 2022
63a44ff
Merge in latest
dfeinzimer Aug 10, 2022
6fc4d81
Merge branch 'v.next' into df/FloatingPanelEnhancements
dfeinzimer Aug 11, 2022
ebfb7ea
Merge branch 'v.next' into df/FloatingPanelEnhancements
dfeinzimer Aug 18, 2022
1af0791
Merge branch 'v.next' into df/FloatingPanelEnhancements
dfeinzimer Aug 22, 2022
6d0175c
`makeHandleArea` -> `makeHandleView`
dfeinzimer Aug 22, 2022
5352247
Doc simplification
dfeinzimer Aug 22, 2022
c75e7e3
Update FloatingPanel.swift
dfeinzimer Aug 22, 2022
2b82038
Doc updates
dfeinzimer Aug 22, 2022
d695cbb
Update FloatingPanelDetent.swift
dfeinzimer Aug 22, 2022
efe7102
Update FloatingPanel.swift
dfeinzimer Aug 22, 2022
9c76d72
Remove deprecated method
dfeinzimer Aug 22, 2022
77e6867
Move padding location
dfeinzimer Aug 22, 2022
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
9 changes: 2 additions & 7 deletions Examples/Examples/FloatingPanelExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import ArcGISToolkit
import ArcGIS

struct FloatingPanelExampleView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass

@StateObject private var map = Map(basemapStyle: .arcGISImagery)

private let initialViewpoint = Viewpoint(
Expand All @@ -30,11 +28,8 @@ struct FloatingPanelExampleView: View {
map: map,
viewpoint: initialViewpoint
)
.overlay(alignment: .topTrailing) {
FloatingPanel {
SampleContent()
}
.frame(maxWidth: horizontalSizeClass == .regular ? 360 : .infinity)
.floatingPanel(isPresented: .constant(true)) {
SampleContent()
}
}
}
Expand Down
26 changes: 11 additions & 15 deletions Examples/Examples/UtilityNetworkTraceExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,18 @@ struct UtilityNetworkTraceExampleView: View {
self.mapPoint = mapPoint
self.mapViewProxy = mapViewProxy
}
.overlay(alignment: .topTrailing) {
FloatingPanel {
UtilityNetworkTrace(
graphicsOverlay: $resultGraphicsOverlay,
map: map,
mapPoint: $mapPoint,
viewPoint: $viewPoint,
mapViewProxy: $mapViewProxy,
viewpoint: $viewpoint
)
.task {
await ArcGISRuntimeEnvironment.credentialStore.add(try! await .publicSample)
}
.floatingPanel(isPresented: .constant(true)) {
UtilityNetworkTrace(
graphicsOverlay: $resultGraphicsOverlay,
map: map,
mapPoint: $mapPoint,
viewPoint: $viewPoint,
mapViewProxy: $mapViewProxy,
viewpoint: $viewpoint
)
.task {
await ArcGISRuntimeEnvironment.credentialStore.add(try! await .publicSample)
}
.padding()
.frame(width: 360)
}
}
}
Expand Down
127 changes: 110 additions & 17 deletions Sources/ArcGISToolkit/Components/FloatingPanel/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,56 +23,76 @@ import SwiftUI
/// or persistent, where the information is always displayed, for example a
/// dedicated search panel. They will also be primarily simple containers
/// that clients will fill with their own content.
public struct FloatingPanel<Content>: View where Content: View {
// Note: instead of the FloatingPanel being a view, it might be preferable
// to have it be a view modifier, similar to how SwiftUI doesn't have a
// SheetView, but a modifier that presents a sheet.

struct FloatingPanel<Content>: View where Content: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass

/// The background color of the floating panel.
let backgroundColor: Color

/// The content shown in the floating panel.
let content: Content

/// Creates a `FloatingPanel`
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
/// - Parameter backgroundColor: The background color of the floating panel.
/// - Parameter detent: Controls the height of the panel.
/// - Parameter isPresented: A Boolean value indicating if the view is presented.
/// - Parameter content: The view shown in the floating panel.
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
public init(@ViewBuilder content: () -> Content) {
init(
backgroundColor: Color,
detent: Binding<FloatingPanelDetent>,
isPresented: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self.backgroundColor = backgroundColor
self.content = content()
_activeDetent = detent
_isPresented = isPresented
}

/// A binding to the currently selected detent.
@Binding private var activeDetent: FloatingPanelDetent

/// The color of the handle.
@State private var handleColor: Color = .defaultHandleColor

/// The height of the content.
@State private var height: CGFloat = .infinity
@State private var height: CGFloat = .minHeight

/// A binding to a Boolean value that determines whether the view is presented.
@Binding private var isPresented: Bool

/// The maximum allowed height of the content.
@State private var maximumHeight: CGFloat = .infinity

/// A Boolean value indicating whether the panel should be configured for a compact environment.
private var isCompact: Bool {
horizontalSizeClass == .compact
horizontalSizeClass == .compact && verticalSizeClass == .regular
}

public var body: some View {
GeometryReader { geometryProxy in
VStack {
if isCompact {
if isCompact && isPresented {
Handle(color: handleColor)
.gesture(drag)
Divider()
content
.frame(minHeight: .minHeight, maxHeight: height)
} else {
content
.frame(minHeight: .minHeight, maxHeight: height)
}
content
.frame(minHeight: .zero, maxHeight: height)
if !isCompact && isPresented {
Divider()
Handle(color: handleColor)
.gesture(drag)
}
}
.esriBorder()
.padding(isCompact ? [] : [.leading, .top, .trailing])
.padding(.bottom, isCompact ? 0 : 50)
.padding([.top, .bottom], 10)
.background(backgroundColor)
.cornerRadius(10, corners: isCompact ? [.topLeft, .topRight] : [.allCorners])
.shadow(radius: 10)
.opacity(isPresented ? 1.0 : .zero)
.padding([.leading, .top, .trailing], isCompact ? 0 : 10)
.padding([.bottom], isCompact ? 0 : 50)
.frame(
width: geometryProxy.size.width,
height: geometryProxy.size.height,
Expand All @@ -84,6 +104,20 @@ public struct FloatingPanel<Content>: View where Content: View {
height = maximumHeight
}
}
.onChange(of: activeDetent) { _ in
withAnimation {
height = heightFor(detent: activeDetent)
}
}
.onChange(of: isPresented) {
height = $0 ? heightFor(detent: activeDetent) : .zero
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
}
.onAppear {
withAnimation {
height = heightFor(detent: activeDetent)
}
}
.animation(.default, value: isPresented)
}
}

Expand All @@ -107,8 +141,32 @@ public struct FloatingPanel<Content>: View where Content: View {
}
.onEnded { _ in
handleColor = .defaultHandleColor
withAnimation {
height = heightFor(detent: closestDetent)
}
}
}

/// The detent that would produce a height that is closest to the current height
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
var closestDetent: FloatingPanelDetent {
return FloatingPanelDetent.allCases.min {
abs(heightFor(detent: $0) - height) <
abs(heightFor(detent: $1) - height)
} ?? .half
}

/// - Parameter detent: The detent to use when calculating height
/// - Returns: A height for the provided detent based on the current maximum height
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
func heightFor(detent: FloatingPanelDetent) -> CGFloat {
switch detent {
case .summary:
return max(.minHeight, maximumHeight * 0.15)
case .half:
return maximumHeight * 0.4
case .full:
return maximumHeight * 0.90
}
}
}

/// The "Handle" view of the floating panel.
Expand All @@ -131,3 +189,38 @@ private extension Color {
static var defaultHandleColor: Color { .secondary }
static var activeHandleColor: Color { .primary }
}

private struct RoundedCorners: Shape {
var corners: UIRectCorner

var radius: CGFloat

func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(
width: radius,
height: radius
)
)
return Path(path.cgPath)
}
}

private extension View {
/// Clips this view to its bounding frame, with the specified corner radius, on the specified corners.
/// - Parameters:
/// - corners: The corners to be rounded.
/// - Returns: A view that clips this view to its bounding frame with the specified corner radius and
/// corners.
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
func cornerRadius(
_ radius: CGFloat,
corners: UIRectCorner
) -> some View {
clipShape(RoundedCorners(
corners: corners,
radius: radius
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright 2022 Esri.

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

/// A value that represents a height where a sheet naturally rests.
public enum FloatingPanelDetent: CaseIterable {
case summary
case half
case full
dfeinzimer marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2022 Esri.

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import SwiftUI

public extension View {
/// A floating panel is a view that overlays a view and supplies view-related
/// content. For a map view, for instance, it could display a legend, bookmarks, search results, etc..
/// Apple Maps, Google Maps, Windows 10, and Collector have floating panel
/// implementations, sometimes referred to as a "bottom sheet".
///
/// Floating panels are non-modal and can be transient, only displaying
/// information for a short period of time like identify results,
/// or persistent, where the information is always displayed, for example a
/// dedicated search panel. They will also be primarily simple containers
/// that clients will fill with their own content.
///
/// The floating panel allows for interaction with background contents, unlike native sheets or popovers.
///
/// - Parameters:
/// - backgroundColor: The background color of the floating panel.
/// - detent: A binding to the currently selected detent.
/// - horizontalAlignment: The horizontal alignment of the floating panel.
/// - isPresented: A binding to a Boolean value that determines whether the view is presented.
/// - maxWidth: The maximum width of the floating panel.
/// - content: A closure that returns the content of the floating panel.
/// - Returns: A dynamic view with a presentation style similar to that of a sheet in compact
/// environments and a popover otherwise.
func floatingPanel<Content>(
backgroundColor: Color = Color(uiColor: .systemBackground),
detent: Binding<FloatingPanelDetent> = .constant(.half),
horizontalAlignment: HorizontalAlignment = .trailing,
isPresented: Binding<Bool> = .constant(true),
maxWidth: CGFloat = 400,
_ content: @escaping () -> Content
) -> some View where Content: View {
modifier(
FloatingPanelModifier(
backgroundColor: backgroundColor,
detent: detent,
horizontalAlignment: horizontalAlignment,
isPresented: isPresented,
maxWidth: maxWidth,
panelContent: content()
)
)
}
}

/// Overlays a floating panel on the parent content.
private struct FloatingPanelModifier<PanelContent>: ViewModifier where PanelContent: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass

/// A Boolean value indicating whether the environment is compact.
private var isCompact: Bool {
horizontalSizeClass == .compact && verticalSizeClass == .regular
}

/// The background color of the floating panel.
let backgroundColor: Color

/// A binding to the currently selected detent.
let detent: Binding<FloatingPanelDetent>

/// The horizontal alignment of the floating panel.
let horizontalAlignment: HorizontalAlignment

/// A binding to a Boolean value that determines whether the view is presented.
let isPresented: Binding<Bool>

/// The maximum width of the floating panel.
let maxWidth: CGFloat

/// The content to be displayed within the floating panel.
let panelContent: PanelContent

func body(content: Content) -> some View {
content
.overlay(alignment: Alignment(horizontal: horizontalAlignment, vertical: .top)) {
FloatingPanel(
backgroundColor: backgroundColor,
detent: detent,
isPresented: isPresented
) {
panelContent
}
.edgesIgnoringSafeArea(.bottom)
.frame(maxWidth: isCompact ? .infinity : maxWidth)
}
}
}