Giter Club home page Giter Club logo

splitview's Introduction

iOS 15.6+ MacCatalyst 15.6+ MacCatalyst 12.4+ Mastodon: @stevengharris@mastodon.social

SplitView

The Split, HSplit, and VSplit views and associated modifiers let you:

  • Create a single view containing two views, arranged in a horizontal (side-by-side) or vertical (above-and-below) layout separated by a draggable splitter for resizing.
  • Specify the fraction of full width/height for the initial position of the splitter.
  • Programmatically hide either view and change the layout.
  • Arbitrarily nest split views.
  • Constrain the splitter movement by specifying minimum fractions of the full width/height for either or both views.
  • Drag-to-hide, so when you constrain the fraction on a side, you can hide the side when you drag more than halfway beyond the constraint.
  • Prioritize either of one the views to maintain its width/height as the containing view changes size.
  • Easily save the state of fraction, layout, and hide so a split view opens in its last state between application restarts.
  • Use your own custom splitter or the default Splitter.
  • Make splitters "invisible" (i.e., zero visibleThickness) but still draggable for resizing.
  • Monitor splitter movement in realtime, providing a simple way to create a custom slider.

Motivation

NavigationSplitView is fine for a sidebar and for applications that conform to a nice master-detail type of model. On the other hand, sometimes you just need two views to sit side-by-side or above-and-below each other and to adjust the split between them. You also might want to compose split views in ways that make sense in your own application context.

Demo

SplitView

This demo is available in the Demo directory as SplitDemo.xcodeproj.

Usage

Install the package.

  • To split two views horizontally, use an HSplit view.
  • To split two views vertically, use a VSplit view.
  • To split two views whose layout can be changed between horizontal and vertical, use a Split view.

Note: You can also use the .split, .vSplit, and .hSplit view modifiers that come with the package to create a Split, VSplit, and HSplit view if that makes more sense to you. See the discussion in Style.

Once you have created a Split, HSplit, or VSplit view, you can use view modifiers on them to:

  • Specify the initial fraction of the overall width/height that the left/top side should occupy.
  • Identify a side that can be hidden and unhidden.
  • Adjust the style of the default Splitter, including its color and thickness.
  • Place constraints on the minimum fraction each side occupies and which side should be prioritized (i.e., remain fixed in size) as the containing view's size changes.
  • Provide a custom splitter.
  • Be able to toggle layout between horizontal and vertical. This modifier is only available for the Split view, since HSplit and VSplit remain in a horizontal or vertical layout by definition.

In its simplest form, the HSplit and VSplit views look like this:

HSplit(left: { Color.red }, right: { Color.green })
VSplit(top: { Color.red }, bottom: { Color.green })

The HSplit is a horizontal split view, evenly split between red on the left and green on the right. The VSplit is a vertical split view, evenly split between red on the top and green on the bottom. Both views use a default splitter between them that can be dragged to change the red and green view sizes.

If you want to set the the initial position of the splitter, you can use the fraction modifier. Here it is being used with a VSplit view:

VSplit(top: { Color.red }, bottom: { Color.green })
    .fraction(0.25)

Now you get a red view above the green view, with the top occupying 1/4 of the window.

Often you want to hide/show one of the views you split. You can do this by specifying the side to hide. Specify the side using a SplitSide. For an HSplit view, you can identify the side using .left or .right. For a VSplit view, you can use .top or .bottom. For a Split view (where the layout can change), use .primary or .secondary. In fact, .left, .top, and .primary are all synonyms and can be used interchangably. Similarly, .right, .bottom, and .secondary are synonyms.

Here is an HSplit view that hides the right side when it opens:

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.25)
    .hide(.right)

The green side will be hidden, but you can pull it open using the splitter that will be visible on the right. This isn't usually what you want, though. Usually you want your users to be able to control whether a side is hidden or not. To do this, pass the SideHolder ObservableObject that holds onto the side you are hiding. Similarly the SplitView package comes with a FractionHolder and LayoutHolder. Under the covers, the Split view observes all of these holders and redraws itself if they change.

Here is an example showing how to use the SideHolder with a Button to hide/show the right (green) side:

struct ContentView: View {
    let hide = SideHolder()         // By default, don't hide any side
    var body: some View {
        VStack(spacing: 0) {
            Button("Toggle Hide") {
                withAnimation {
                    hide.toggle()   // Toggle between hiding nothing and hiding right
                }
            }
            HSplit(left: { Color.red }, right: { Color.green })
                .hide(hide)
        }
    }
}

Note that the hide modifier accepts a SplitSide or a SideHolder. Similarly, layout can be passed as a SplitLayout - .horizontal or .vertical - or as a LayoutHolder. And fraction can be passed as a CGFloat or as a FractionHolder.

The toggle() method on hide toggles the hide/show state for the secondary side by default. If you want to toggle the hide/show state for a specific side, then use toggle(.primary) or toggle(.secondary) explicitly. (Note that .primary, .left, and .top are synonyms; and .secondary, .right, and .bottom are synonyms.)

Nesting Split Views

Split views themselves can be split. Here is an example where the right side of an HSplit is a VSplit that has an HSplit at the bottom:

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { Color.green },
            right: {
                VSplit(
                    top: { Color.red },
                    bottom: {
                        HSplit(
                            left: { Color.blue },
                            right: { Color.yellow }
                        )
                    }
                )
            }
        )
    }
}

And here is one where an HSplit contains two VSplits:

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { 
                VSplit(top: { Color.red }, bottom: { Color.green })
            },
            right: {
                VSplit(top: { Color.yellow }, bottom: { Color.blue })
            }
        )
    }
}

Using UserDefaults For Split State

The three holders - SideHolder, LayoutHolder, and FractionHolder - all come with a static method to return instances that get/set their state from UserDefaults.standard. Let's expand the previous example to be able to change the layout and hide state and to get/set their values from UserDefaults. Note that if you want to adjust the layout, you need to use a Split view, not HSplit or VSplit. We create the Split view by specifying the primary and secondary views. When the SplitLayout held by the LayoutHolder (layout) is .horizontal, the primary view is on the left side, and the secondary view is on the right. When the SplitLayout toggles to vertical, the primary view is on the top, and the secondary view is on the bottom.

struct ContentView: View {
    let fraction = FractionHolder.usingUserDefaults(0.5, key: "myFraction")
    let layout = LayoutHolder.usingUserDefaults(.horizontal, key: "myLayout")
    let hide = SideHolder.usingUserDefaults(key: "mySide")
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Button("Toggle Layout") {
                    withAnimation {
                        layout.toggle()
                    }
                }
                Button("Toggle Hide") {
                    withAnimation {
                        hide.toggle()
                    }
                }
            }
            Split(primary: { Color.red }, secondary: { Color.green })
                .fraction(fraction)
                .layout(layout)
                .hide(hide)
        }
    }
}

The first time you open this, the sides will be split 50-50, but as you drag the splitter, the fraction state is also retained in UserDefaults.standard. You can change the layout and hide/show the green view, and when you next open the app, the fraction, hide, and layout will all be restored how you left them.

Modifying And Constraining The Default Splitter

You can change the way the default Splitter displays using the styling modifier. For example, you can change the color, inset, and thickness:

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.25)
    .styling(color: Color.cyan, inset: 4, visibleThickness: 8)

If you prefer the splitter to hide also when you hide a side, you can set hideSplitter to true in the styling modifier. For example:

HSplit(left: { Color.red }, right: { Color.green })
    .styling(hideSplitter: true)

Note that if you set hideSplitter to true, you need to include a means for your user to unhide a view once it is hidden, like a hide/show button. That's because the splitter itself isn't displayed at all, so you can't just drag it out from the side.

By default, the splitter can be dragged across the full width/height of the split view. The constraints modifier lets you constrain the minimum faction of the overall view that the "primary" and/or "secondary" view occupies, so the splitter always stays within those constraints. You can do this by specifying minPFraction and/or minSFraction. The minPFraction refers to left in HSplit and top in VSplit, while minSFraction refers to right in HSplit and bottom in VSplit:

HSplit(left: { Color.red }, right: { Color.green })
    .fraction(0.3)
    .constraints(minPFraction: 0.2, minSFraction: 0.2)

Drag-To-Hide

When you constrain the fraction of the primary or secondary side, you may want the side to hide automatically when you drag past the constraint. However, we need to trigger this drag-to-hide behavior when you drag "well past" the constraint, because otherwise, it's difficult to leave the splitter positioned at the constraint without hiding it. For this reason, a split view defines "well past" to mean "more than halfway past the contraint".

Drag-to-hide can be a nice shortcut to avoid having to press a button to hide a side. You can see an example of it in Xcode when you drag the splitter between the editor area in the middle and the Inspector on the right beyond the constraint Xcode puts on the Inspector width. In Xcode, when you drag-to-hide the splitter between the editor area and the Inspector, you cannot drag it back out because the splitter itself is hidden. You need a button to invoke the hide/show action, as discussed earlier. The same is true with drag-to-hide using a split view when hideSplitter is true.

When your cursor moves beyond the halfway point of the constrained side, the split view previews what it will look like when the side is hidden. This way, you have a visual indication that the side will hide, and you can drag back out to avoid hiding it. If your dragging ends when the side is hidden, then it will remain hidden.

Note that when you use drag-to-hide, the splitter may or may not be hidden when the side is hidden (depending on whether hideSplitter is true in SplitStyling). The preview of what the split view will look like if you release past the halfway point reflects your choice of setting for hideSplitter.

To use drag-to-hide, add dragToHideP and/or dragToHideS to your constraints definition. For example, the following will constrain dragging between 20% and 80% of the width, but when the drag gesture ends at or beyond the 90% mark on the right, the secondary side will hide. Note also that in this case, the primary side doesn't use drag-to-hide:

HSplit(left: { Color.red }, right: { Color.green })
    .constraints(minPFraction: 0.2, minSFraction: 0.2, dragToHideS: true)

Custom Splitters

By default the Split, HSplit, and VSplit views all use the default Splitter view. You can create your own and use it, though. Your custom splitter should conform to SplitDivider protocol, which makes sure your custom splitter can let the Split view know what its styling is. The styling.visibleThickness is the size your custom splitter displays itself in, and it also defines the spacing between the primary and secondary views inside of Split view.

The Split view detects drag events occurring in the splitter. For this reason, you might want to use a ZStack with an underlying Color.clear that represents the styling.invisibleThickness if the styling.visibleThickness is too small for properly detecting the drag events.

Here is an example custom splitter whose contents is sensitive to the observed layout and hide state:

struct CustomSplitter: SplitDivider {
    @ObservedObject var layout: LayoutHolder
    @ObservedObject var hide: SideHolder
    @ObservedObject var styling: SplitStyling
    /// The `hideButton` state tells whether the custom splitter hides the button that normally shows
    /// in the middle. If `styling.previewHide` is true, then we only want to show the button if
    /// `styling.hideSplitter` is also true.
    /// In general, people using a custom splitter need to handle the layout when `previewHide`
    /// is triggered and that layout may depend on whether `hideSplitter` is `true`.
    @State private var hideButton: Bool = false
    let hideRight = Image(systemName: "arrowtriangle.right.square")
    let hideLeft = Image(systemName: "arrowtriangle.left.square")
    let hideDown = Image(systemName: "arrowtriangle.down.square")
    let hideUp = Image(systemName: "arrowtriangle.up.square")
    
    var body: some View {
        if layout.isHorizontal {
            ZStack {
                Color.clear
                    .frame(width: 30)
                    .padding(0)
                if !hideButton {
                    Button(
                        action: { withAnimation { hide.toggle() } },
                        label: {
                            hide.side == nil ? hideRight.imageScale(.large) : hideLeft.imageScale(.large)
                        }
                    )
                    .buttonStyle(.borderless)
                }
            }
            .contentShape(Rectangle())
            .onChange(of: styling.previewHide) { hide in
                hideButton = styling.hideSplitter
            }
        } else {
            ZStack {
                Color.clear
                    .frame(height: 30)
                    .padding(0)
                if !hideButton {
                    Button(
                        action: { withAnimation { hide.toggle() } },
                        label: {
                            hide.side == nil ? hideDown.imageScale(.large) : hideUp.imageScale(.large)
                        }
                    )
                    .buttonStyle(.borderless)
                }
            }
            .contentShape(Rectangle())
            .onChange(of: styling.previewHide) { hide in
                hideButton = styling.hideSplitter
            }
        }
    }}

You can use the CustomSplitter like this:

struct ContentView: View {
    let layout = LayoutHolder()
    let hide = SideHolder()
    let styling = SplitStyling(visibleThickness: 20)
    var body: some View {
        Split(primary: { Color.red }, secondary: { Color.green })
            .layout(layout)
            .hide(hide)
            .splitter { CustomSplitter(layout: layout, hide: hide, styling: styling) }
    }
}

If you make a custom splitter that would be generally useful to people, consider filing a pull request for an additional Splitter extension in Splitter+Extensions.swift. The line Splitter is included in the file as an example that is used in the "Sidebars" demo. Similarly, the invisible Splitter re-uses the line splitter by passing a visibleThickness of zero and is used in the "Invisible splitter" demo.

Invisible Splitters

You might want the views you split to be adjustable using the splitter, but for the splitter itself to be invisible. For example, a "normal" sidebar doesn't show a splitter between itself and the detail view it sits next to. You can do this by passing Splitter.invisible() as the custom splitter.

One thing to watch out for with an invisible splitter is that when a side is hidden, there is no visual indication that it can be dragged back out. To prevent this issue, you should specify minPFraction and minSFraction when using Splitter.invisible().

struct ContentView: View {
    let hide = SideHolder()
    var body: some View {
        VStack(spacing: 0) {
            Button("Toggle Hide") {
                withAnimation {
                    hide.toggle()   // Toggle between hiding nothing and hiding secondary
                }
            }
            HSplit(left: { Color.red }, right: { Color.green })
                .hide(hide)
                .constraints(minPFraction: 0.2, minSFraction: 0.2)
                .splitter { Splitter.invisible() }
        }
    }
}

Monitoring And Responding To Splitter Movement

You can specify a callback for the split view to execute as you drag the splitter. The callback reports the privateFraction being tracked; i.e., the fraction of the full width/height occupied by the left/top side. Specify the callback using the onDrag(_:) modifier for any of the split views.

Here is an example of a DemoSlider that uses the onDrag(_:) modifier to update a Text view showing the percentage each side is occupying.

struct DemoSlider: View {
    @State private var privateFraction: CGFloat = 0.5
    var body: some View {
        HSplit(
            left: {
                ZStack {
                    Color.green
                    Text(percentString(for: .left))
                }
            },
            right: {
                ZStack {
                    Color.red
                    Text(percentString(for: .right))
                }
            }
        )
        .onDrag { fraction in privateFraction = fraction }
        .frame(width: 400, height: 30)
    }

    /// Return a string indicating the percentage occupied by `side`
    func percentString(for side: SplitSide) -> String {
        var percent: Int
        if side.isPrimary {
            percent = Int(round(100 * privateFraction))
        } else {
            percent = Int(round(100 * (1 - privateFraction)))
        }
        // Empty string if the side will be too small to show it
        return percent < 10 ? "" : "\(percent)%"
    }
}

It looks like this:

DemoSlider

Prioritizing The Size Of A Side

When you want a sidebar type of arrangement using HSplit views, you often want the sidebar to maintain its width as you resize the overall view. You might have the same need with a VSplit, too. If you have two sidebars, you may want to slide either one while the opposing one stays the same width. You can accomplish this by specifying a priority side (either .left/.right or .top/.bottom) in the constraints modifier.

Here is an example that has a red left sidebar and green right sidebar surrounding a yellow middle view. As you drag either splitter, the other stays fixed. Under the covers, the Split view is adjusting the proportion between primary and secondary to keep the splitter in the same place. You will also see that as you resize the window, both sidebars maintain their width.

struct ContentView: View {
    var body: some View {
        HSplit(
            left: { Color.red },
            right: {
                HSplit(
                    left: { Color.yellow },
                    right: { Color.green }
                )
                .fraction(0.75)
                .constraints(priority: .right)
            }
        )
        .fraction(0.2)
        .constraints(priority: .left)
    }
}

Note that in the example above, the two sidebars have the same width, which is 0.2 of the overall width, even though the fractions specified for the left and right sides are 0.2 and 0.75 respectively. This is because the left side of the outer HSplit is 0.2 of the overall width, leaving 0.8 to divide in the inner HSplit. The left side of the inner HSplit is 0.75*0.8 or 0.6 of the overall width, leaving the right side of the inner HSplit to be 0.2 of the overall width.

Implementation

The heart of the implementation here is the Split view. VSplit and HSplit are really convenience and clarity wrappers around Split. There is probably not a big need for most people to be able to adjust layout dynamically, which is really the only reason to use Split directly.

Although ultimately Split has to deal in width and height, the math of adjusting the layout is the same whether its primary is at the left or top and its secondary is at the right or bottom.

The main piece of state that changes in Split view is constrainedFraction. This is the fraction of the overall width/height occupied by the primary view. It changes as you drag the splitter. When you hide/show, it does not change, because it holds the state needed to restore-to when a hidden view is shown again. The Split view monitors changes to its size. The size changes when its containing view changes size (e.g., resizing a window on the Mac or when nested in another Split view whose splitter is dragged).

The three views, Split, HSplit, and VSplit all support the same modifiers to adjust fraction, hide, styling, constraints, onDrag, and splitter. The Split view also has a modifier for layout (which is also used by HSplit and VSplit) and a few convenience modifiers used by HSplit and VSplit.

Style

After going all-in on a View modifier style to return a single Split-type of view for any View it is invoked on, I read an article by John Sundell that illustrated some of the "problematic" issues associated with view modifiers creating different container views. As a result, I reconsidered my approach. I'm still using view modifiers extensively, but now they operate on an explicit Split, HSplit, or VSplit container, and always return the same type of view they modify. I think this makes usage a lot more clear in the end.

If you prefer the idea of a View modifier to kick off your Split, HSplit, or VSplit creation, you can still use:

Color.green.hSplit { Color.red }   // Returns an HSplit
Color.green.vSplit { Color.red }   // Returns a VSplit
Color.green.split { Color.red }    // Returns a Split

instead of:

HSplit(left: { Color.green }, right: { Color.red } )
VSplit(top: { Color.green }, bottom: { Color.red } )
Split(primary: { Color.green }, secondary: { Color.red })

Issues

  1. In versions prior to MacOS 14.0 Sonoma, there is what appears to be a harmless log message when dragging the Splitter to cause a view size to go to zero on Mac Catalyst only. The message shows up in the Xcode console as [API] cannot add handler to 3 from 3 - dropping. This message is not present as of MacOS 14.0 Sonoma.

  2. The Splitter's onHover entry action used to display the resizing cursors on Mac Catalyst and MacOS may occasionally not be triggered when using nested split views. I think this happens seldom enough to not be a problem. When it occurs, the cursor doesn't change to resizeLeftRight or resizeUpDown when hovering over a splitter, but the splitter will still be draggable.

Possible Enhancements

I might add a few things but would be very happy to accept pull requests! For example, a split view that adapted to device orientation and form factors somewhat like NavigationSplitView would be useful.

History

Version 3.5

  • Publish changes to fraction, so that setting the value externally changes the split view layout (Issue 29).
  • Allow use of toggle(.primary) or toggle(.secondary) as a way to specify the side to hide/show (thanks Bastiaan Terhorst).
  • Fix display bug when specifying minFractionP and hiding primary side (Issue 31).
  • Remove forced hiding of splitter when hiding a side with minFractionP or minFractionS specified (Issue 30).
  • Update README to reflect changes.

Version 3.4

  • Refactor so that Splitter holds SplitStyling, allowing custom splitters to participate properly in drag-to-hide.
  • Incompatible change to SplitDivider protocol to expose styling: SplitStyling rather than visibleThickness. The incompatibility only affects you if you were using a custom splitter.

Version 3.3

  • Support drag-to-hide with a preview of the side being hidden as you drag beyond the halfway point of the constrained side. See the Drag-To-Hide section.

Version 3.2

  • Display resizing cursors on Mac Catalyst and MacOS when hovering over the splitter.
  • Add ability to hide a side when dragging completes at a point beyond the minimum constraints. See the Drag-To-Hide section.
  • Add ability to hide the splitter when a side is hidden. See the information on hideSplitter in the Modifying And Constraining The Default Splitter section.

Version 3.1

Version 3.0

  • Incompatible change from Version 2 to change from an extensive set of View modifiers to explicit use of Split, HSplit, and VSplit. Most of the previous version's split View modifiers have been removed in this version.
  • Modify the DemoApp to use the new Split, HSplit, and VSplit approach. Functionality is unchanged.

Version 2.0

  • Incompatible change from Version 1 in split enums. SplitLayout cases change from .Horizontal and .Vertical to .horizontal and .vertical. SplitSide cases change from .Primary and .Secondary to .primary and .secondary.
  • Add ability to specify a side (.primary or .secondary) that has sizing priority. The size of the priority side remains unchanged as its containing view resizes. If priority is not specified - the default - then the proportion between primary and secondary is maintained. This enables proper sidebar type of behavior, where changing one sidebar's size does not affect the other.
  • Add a sidebar demo showing the use of priority.

Version 1.1

  • Generalize the way configuration of SplitView properties are handled using SplitConfig, which can optionally be passed to the split modifier. There is a minor compatibility change in that properties such as color and visibleThickness must be passed to the default Splitter using SplitConfig.
  • Allow minimum fractions - minPFraction and minSFraction - to be configured in SplitConfig to constrain the size of the primary and/or secondary views.
  • If a minimum fraction is specified for a side and that side is hidden, then the splitter will be hidden, too. The net effect of this change is that the hidden side cannot be dragged open when it is hidden and a minimum fraction is specified for a side. It can still be unhidden by changing its SideHolder. Under these conditions, the unhidden side occupies the full width/height when the other is hidden, without any inset for the splitter.

Version 1.0

Make layout adjustable. Clean up and formalize the SplitDemo, including the custom splitter and "invisible" splitter. Update the README.

Version 0.2

Eliminates the use of the clear background and SizePreferenceKeys. (My suspicion is they were needed earlier because GeometryReader otherwise caused bad behavior, but in any case they are not needed now.) Eliminate HSplitView and VSplitView, which were themselves holding onto a SplitView. The layering was both unnecessary and not adding value other than making it explicit what kind of SplitView was being created. I concluded that the same expression was actually clearer and more concise using ViewModifiers. I also added the Example.xcworkspace.

Version 0.1

Originally posted in response to https://stackoverflow.com/q/67403140. This version used HSplitView and VSplitView as a means to create the SplitView. It also used SizePreferenceKeys from a GeometryReader on a clear background to set the size. In nested SplitViews, I found this was causing "Bound preference ... tried to update multiple times per frame" to happen intermittently depending on the view arrangement.

splitview's People

Contributors

ccwork288 avatar stevengharris avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

splitview's Issues

Setting fraction programmatically (after the split view is shown already)

I couldn't figure out a way how to set the split fraction between primary and secondary view programmatically (e.g. by manipulating the FractionHolder). To me it looks like the fraction holder gets updated after the drag is over, but any changes to the value of the fraction holder are not propagated into the view. I'd like to provide a button that resets the split fraction to 0.5. Am I right in assuming that this is currently not possible?

Set SplitView Constraints Maximum

Currently there only appears to be the ability to set a constraint to the minimum size. Being able to set the maximum would also be useful.

Breakpoints & animations

I'm planning on extending VSplit capabilities by breakpoints in my fork, which will result in something like PresentationDetents in sheets, or SwipeActions in List.

If there's any need for that in the original repo I'd be glad to create pull request for that once it's done.
Please share some API style guidelines if there are any requirements.

Default splitter

I believe these splitter specs are reasonably close to the iOS system default:

bar color: Color.secondary.opacity(colorScheme == .dark ? 0.7 : 0.5)
bar thickness: 5
bar invisible thickness (but also as padding, not just an unclipped ZStack): I'm not sure but the default has some padding typically of approximately 16 or so on either side, could measure this
Shape: Capsule rather than RoundedRectangle

On macOS I believe it's simply a Divider() by default, with some tolerance on either side.

Secondly, there's no precedence for split views on iOS. Even iPad splitters are static and unmovable Divider()s. So I recommend (if you wanted to make this view fully native feeling out of the box) having something that behaves more like a sheet/popover on iOS, at least in "compact" mode, such as that it has rounded corners at one end, or perhaps even in fact using the new presentationDetent APIs with the very new API for allowing interaction with an undimmed background. However that's less flexible than this currently so some visual stand-in can work.

It's trivial at least to have a groupbox for a view, and a clear background so that the divider floats between rounded groupbox containers. That would feel native on iOS and not require library changes

Feature: minimum split size

To mimic system components, the split should collapse when it reaches a size threshold and should only reappear when dragged back beyond that threshold or toggled back on. See NavigationSplitView behavior for example.

Sticky buttons in secondary views or on press repeat

Hello again,

There seems to be an issue with the secondary views whereby buttons ignore presses until click spamming reactivates them. This problem also seems to occur on primary views when repeating the same button action. For reference, I'm trying to reproduce the Xcode pan behavior where unlimited panes can be opened with the ability to dismiss themselves.

Here's my relevant code:

import SwiftUI

struct SplitStateHolder: Identifiable {
    var id: UUID = UUID()
    var layout: LayoutHolder
    var hide: SideHolder
}

struct DICOMSplitView: View {
    
    var holders = [
        SplitStateHolder(layout: LayoutHolder(.horizontal), hide: SideHolder()), // main vertical split
        SplitStateHolder(layout: LayoutHolder(.vertical), hide: SideHolder()), // left horizontal split
        SplitStateHolder(layout: LayoutHolder(.vertical), hide: SideHolder()), // right horizontal split
    ]

    var body: some View {
        Split(
            primary: {
                Split(
                    primary: {
                        DICOMDetailView(verticalHide: holders[0].hide, horizontalHide: holders[1].hide, isPrimary: true)
                    },
                    secondary: {
                        DICOMDetailView(verticalHide: holders[0].hide, horizontalHide: holders[1].hide, isPrimary: false)
                    }
                )
                .layout(holders[1].layout)
                .hide(holders[1].hide)
                .styling(color: Color.secondary.opacity(0.3), inset: 0, visibleThickness: 1)
            },
            secondary: {
                Split(
                    primary: {
                        DICOMDetailView(verticalHide: holders[0].hide, horizontalHide: holders[2].hide, isPrimary: true)
                    },
                    secondary: {
                        DICOMDetailView(verticalHide: holders[1].hide, horizontalHide: holders[2].hide, isPrimary: false)
                            
                    }
                )
                .layout(holders[2].layout)
                .hide(holders[2].hide)
                .styling(color: Color.secondary.opacity(0.3), inset: 0, visibleThickness: 1)
            }
        )
        .layout(holders[0].layout)
        .hide(holders[0].hide)
        .styling(color: Color.secondary.opacity(0.3), inset: 0, visibleThickness: 1)
    }
}
import SwiftUI

struct DICOMDetailView: View {
    
    @ObservedObject var verticalHide: SideHolder
    @ObservedObject var horizontalHide: SideHolder
    
    var isPrimary: Bool = true

    var body: some View {
        VStack {
            Image("test")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(minHeight: 0)
                .clipped()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        
        .safeAreaInset(edge: .top, spacing: 0) {
            VStack(alignment: .center, spacing: 0) {
                HStack(alignment: .center) {
                    Button {
                        // Show studies
                    } label: {
                        Label("Show all studies", systemImage: "square.grid.2x2")
                            .labelStyle(.iconOnly)
                    }
                    .keyboardShortcut("H", modifiers: [.shift, .command])
                    .help("Show studies")
                    .padding(.leading, 10)
                    
                    Divider().padding(.vertical, 5)
                    HStack(alignment: .center) {
                        
                        Image(systemName: "lungs.fill")
                            .resizable()
                            .frame(width: 10, height: 10)
                        Text("NOID.dcm")
                    }
                    Spacer()
                    if isPrimary {
                        Divider().padding(.vertical, 5)
                        Button {
                            print("I'm pressed")
                            // Split viewing pane
                            withAnimation {
                                verticalHide.toggle()
                            }
                        } label: {
                            Label("Split horizontally", systemImage: "square.split.2x1")
                                .labelStyle(.iconOnly)
                        }
                        .help("Split the current view")
//                        .padding(.trailing, 10)
                        
                        Button {
                            print("I'm pressed")
                            // Split viewing pane
                            withAnimation {
                                horizontalHide.toggle()
                            }
                        } label: {
                            Label("Split horizontally", systemImage: "square.split.1x2")
                                .labelStyle(.iconOnly)
                        }
                        .help("Split the current view")
                        .padding(.trailing, 10)
                    }
                }
                Divider()
            }
            .frame(height: 25)
        }
    }
}

divider's visual bug

// …
let lh = LayoutHolder(.vertical)
// …
Split(primary: { Color.red }, secondary: { TabView { Color.green } })
    .layout(lh)
Button("bug") { withAnimation { lh.toggle() } }
Button("ok") { lh.toggle() }

// …
Screenshot 2023-09-21 at 22 17 27

CustomSplitter disappears when constraints is set

When I add .constraints(minPFraction:minSFraction) the CustomSplitter disappears. Which should not happen.

CleanShot 2023-11-29 at 10 07 59

Sample code:

let layout0 = demo.holders[0].layout
let hide0 = demo.holders[0].hide
let styling = SplitStyling(visibleThickness: 20)
Split(
    primary: { Color.green },
    secondary: { Color.red }
)
.splitter { DemoSplitter(layout: layout0, hide: hide0, styling: styling) }
.layout(layout0)
.hide(hide0)
.constraints(minPFraction: 0.2, minSFraction: 0.2, dragToHideS: true)

Is there something I am doing wrong or how can I solve it?

Thanks in advance

Nested views ad infinitum

Hello,

Hope you're well! This is a great library! I was wondering if it was possible to use it to create nested views continuously without predefining the layout. So given an initial view, the user clicks a button to split it in half. Given the two new views, the user can click a same button to split it etc.

Thanks!

Bug when hiding splitview while using minimum fractions

Hi,
I'm using a simple horizontal split set up like this:

@State private var minPFraction = 0.1 // 100 at default width of 1000

--snip--

           VStack {
                Split(primary: {
                    Color.teal
                }, secondary: {
                    Color.green
                })
                .constraints(minPFraction: minPFraction, priority: .left, dragToHideP: true)
                .fraction(fraction)
                .hide(hide)
                
                Button("Toggle Hide") {
                    withAnimation {
                            if(hide.side == .left) {
                                hide.toggle()
                            } else {
                                hide.hide(.left)
                            }
                    }
                }
            }

When I click hide to toggle hiding the left split it hides but the whole split narrows in width revealing the content below the split view on the right side:

Screen.Recording.2023-12-10.at.15.51.51.mov

On hiding the left split seems to resize to minPFraction. And when hiding by dragging to the left, that hidden state seems not to be in sync with whatever .hide() is using as clicking the hide button doesn't unhide the left split, but does what looks like another hide operation.

Is there something obvious I am missing? :)

Thanks!

PS
As an aside: it was a bit confusing to get the left split to hide! hide.toggle() always hides the right view, regardless of which one has priority (I expect in most scenario's the split having priority is the one to be hidden -- but I could be wrong). I did get it to work with the code above, but IMHO an API like .toggle(.left) would be cleaner.

Set SplitView Constraints by absolute number

Currently it is only possible to set constraints based on fractional sizes of the overall view. Being able to set an absolute size would allow for minimum that can not shrink beyond a certain size requirement.

Hidden view still visible partly

  • a HSplit view is used with a with VSplit view inside
  • the right side is hidden

and the min, max constraints are not set,

Than a small part of the right side view (around 10 pixel) are shown over the left side view. This can be removed by either padding the right inside view or adding the constraints.

HSplit(left: {
     VSplit(top: {
                    },
                    bottom: {
                    })
                    .fraction(0.5)
                    .styling(color: Color.blue, inset: 30, visibleThickness: 1)
                    //.padding(.trailing, 0)
                },
                right: {
                })
                .fraction(0.6)
                .constraints(minPFraction: 0.5, minSFraction: 0.3) <-- this line makes the difference
                .styling(color: Color.blue, inset: 30, visibleThickness: 1)
                .hide(self.showDetails)
            }

Issue When Dragging a Splitter Outside the Parent Window on MacOS

If you drag outside the window on MacOS the position of the cursor relative to the splitter is changed by the distance travelled outside the window. When you reenter the window your pointer is no longer over the splitter.

BTW - This is a great library. It is exactly what I needed.

Constraints for Sliding Splitters

Splitters can currently be dragged across the full width/height of the SplitView containing them. They need a way to be constrained so the primary and/or secondary view can maintain a configurable minimum size/fraction.

UserDefaults - working ?

Great component - tried to work direction with HSplitView ... not easy to go beyond basics.

I have a UI with multiple tabs - 2 tabs are using Split but I want each to have them save their position to UserDefaults:

`
let snippetFraction = FractionHolder.usingUserDefaults(Constants.Size.snippetFraction, key: "snippetFraction")

let templateFraction = FractionHolder.usingUserDefaults(Constants.Size.templateFraction, key: "templateFraction")
`

But, when switching tabs, the fraction value is still default - and wasn't sure if you could use your own keys.

Otherwise - thanks for this - saves lots of effort - great job !

Instantiating from a simpler format?

Is there a way to instantiate a layout from a simpler kind of format, which just shows the position and size of each View.

(this ignores colours, which would have to be passed via JSON somehow)

So a four-view layout (2 by 2) could be expressed like so,

// json, numbers are percentages
{
    "views": [
        {
            "width": 50,
            "height": 50,
            "x": 0,
            "y": 0
        },
        {
            "width": 50,
            "height": 50,
            "x": 50,
            "y": 0
        },
        {
            "width": 50,
            "height": 50,
            "x": 0,
            "y": 50
        },
        {
            "width": 50,
            "height": 50,
            "x": 50,
            "y": 50
        }
    ]
}

// swift
struct SView: Identifiable, Codable, Hashable {
    var id = UUID()
    let width: CGFloat
    let height: CGFloat
    let x: CGFloat
    let y: CGFloat

    enum CodingKeys: String, CodingKey {
        case width, height, x, y
    }
}

Custom splitter using drag-to-hide

The new drag-to-hide feature does not work properly with custom splitters, or at least with the DemoSplitter provided with the package. That's because drag-to-hide depends on the previewHide published property of SplitStyling, and there really isn't an easy way to observe the change to styling.previewHide. Further, the visibleThickness of SplitDivider protocol - which was the way the Split view and custom splitters communicate what the visibleThickness is to separate the split views - is already held in SplitSettings, making dealing with previewHide more difficult.

To fix the situation, SplitDivider protocol needs to provide access to styling, and the default Splitter needs to hold onto styling, not the Split view. Basically, SplitStyling - which really only applies to how the splitter itself is styled - has to be part of the Splitter itself (and any custom splitter), not the Split view. The various view modifiers all need to be updated to reflect the refactoring.

This change will be compatible with previous versions unless you were using a custom splitter. If you were using a custom splitter, then the SplitDivider protocol you used before has changed to expose styling instead of visibleThickness. The visibleThickness is accessible via the styling (and internally is accommodated in changes to Split view itself). Refer to changes in DemoSplitter if you want a guide to how to adapt any existing custom splitter.

Option to offset views instead of changing frame size

My use case requires me to leave initial P View frame as it is, without resizing onDrag (it's a MapView, resizing it is slow and animates poorly on device).
I adjusted your code to fit my requirements, by calculating the difference between container height and S View offset, and offsetting the P View.

Is it something you'd like to have in this library? I could refactor this and create pull request. I believe some use cases could require a boolean flag for this option.

Change cursor on Mac when inside splitter

Hey, I think it wold be really cool if you could get the same cursor on Mac in this that you get in the finder or Xcode when hovering over the splitter.

Maybe I can dig in and figure it out, but am new to Swift UI so wanted to ask about it. Maybe it's simple somehow?

Thanks!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.