Skip to content

Instantly share code, notes, and snippets.

@janodev
Forked from avielg/viewtree.py
Last active June 19, 2024 21:38
Show Gist options
  • Save janodev/9aac6d40b8dc3e3c71894bd82ae096c4 to your computer and use it in GitHub Desktop.
Save janodev/9aac6d40b8dc3e3c71894bd82ae096c4 to your computer and use it in GitHub Desktop.
SwiftUI View Tree Graph
#!/opt/homebrew/bin/python3
import re
import glob
import os
import graphviz
import sys
## USAGE #########################################################################
# 1. Install
# brew install python3
# pip install graphviz
# 2. Set this to the name of your main SwiftUI view file
MAIN_FILE = 'RootView.swift'
# 4. Call passing the folder containing your sources, e.g.
# python3 viewtree.py Sources/Application
os.environ["PATH"] += os.pathsep + "/opt/homebrew/bin"
# Ignore any `PreferenceKey` and `EnvironmentKey` types
DROP_KEYS = True
# Ignore anything that isn't a `View`
DROP_NON_VIEWS = True
# Style subgraphs differently
STYLE_SUBGRAPH = False
##################################################################################
class Node:
def __init__(self, name, children, inline_children):
self.name = name
self.children = children
self.inline_children = inline_children
found = []
def find_main_file(folder, main_file_name):
for root, dirs, files in os.walk(folder):
if main_file_name in files:
return os.path.join(root, main_file_name)
return None
def analyze(file, folder):
base = os.path.basename(file)
if DROP_NON_VIEWS and not base.endswith("View.swift"):
return None
found.append(file)
base = os.path.basename(file)
viewname = os.path.splitext(base)[0]
children = []
inline_structs = []
with open(file) as f:
lines = f.readlines()
for rawline in lines:
line = rawline.strip()
if line.strip("private").strip("public").strip().startswith("struct"):
regex_result = re.search("struct ([A-Za-z0-9_]*)", line)
if regex_result:
name = regex_result.group(1)
if name != viewname and name not in inline_structs and "_" not in name:
isView = "View" in line
if not DROP_NON_VIEWS or isView:
iskey = "PreferenceKey" in line or "EnvironmentKey" in line
if not DROP_KEYS or not iskey:
inline_structs.append(name)
in_view = False
for rawline in lines:
line = rawline.strip()
is_comment = line.startswith("/*") or line.startswith("*") or line.startswith("//") or line.startswith("import")
is_property_decl = " var " in line or " let " in line
if not is_comment and not is_property_decl:
regex_result = re.search(r"(?!\.)([A-Z][a-zA-z0-9]*)(?!\.)\s*(\(|{)", line)
if regex_result:
name = regex_result.group(1)
if name not in inline_structs and "_" not in name:
for f in glob.glob(os.path.join(folder, '**/' + name + '.swift'), recursive=True):
if f != file:
child = analyze(f, folder)
children.append(child)
return Node(viewname, children, inline_structs)
# keep track of already created edges since we don't care about multiple edges between the same nodes
edgesIDs = []
def graph(dot, node):
if node is None:
return
dot.node(node.name)
for child in node.children:
if child is not None:
id = node.name + child.name
if id not in edgesIDs:
edgesIDs.append(id)
dot.edge(node.name, child.name)
graph(dot, child)
else:
continue
if len(node.inline_children) > 0:
with dot.subgraph(name='cluster_' + node.name) as c:
if STYLE_SUBGRAPH:
c.node_attr['shape'] = 'egg'
c.node_attr['fontcolor'] = 'darkgray'
c.node_attr['style'] = ''
c.graph_attr['style'] = 'dotted'
for inline in node.inline_children:
id = node.name + inline
if id not in edgesIDs:
edgesIDs.append(id)
c.node(inline)
c.edge(node.name, inline)
def run():
if len(sys.argv) != 2:
print("Usage: script.py <folder>")
sys.exit(1)
folder = sys.argv[1]
main_file_path = find_main_file(folder, MAIN_FILE)
if not main_file_path:
print(f"Error: {MAIN_FILE} not found in {folder}")
sys.exit(1)
main_node = analyze(main_file_path, folder)
if main_node is None:
print("No valid View-based main file found.")
return
dot = graphviz.Digraph(comment='View Tree From: ' + main_node.name, node_attr={'shape': 'box', 'style': 'filled'})
graph(dot, main_node)
if STYLE_SUBGRAPH:
source = dot
else:
source = dot.unflatten(stagger=3)
# uncomment this to keep the dot file. Dot files can be edited in Omnigraffle.
# dot.save('./swiftui-views.dot')
source.render('./swiftui-views', format='png', view=True)
if os.path.exists('./swiftui-views'):
os.remove('./swiftui-views')
run()
@janodev
Copy link
Author

janodev commented Jun 19, 2024

Changes

  • Pass a path to source folder as first parameter
  • Find the root view file set in MAIN_FILE
  • When DROP_NON_VIEWS = True (default) ignore files whose filename is not suffixed with View.swift
  • Remove intermediate digraph file at the end
  • Rename generated file to swiftui-views.png
  • Add commented line to save a .dot file
  • Remove printing the nodes to console

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment